Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,11 @@ The Tool resource defines a capability that can be used by an Agent, such as:

### Task

The Task resource represents a request to an Agent, which starts a conversation.
The Task resource represents a request to an Agent, which starts a conversation. Tasks can be created in two ways:
- Using a simple `userMessage` for single-turn queries
- Using a `contextWindow` containing multiple messages for multi-turn conversations or continuing previous chats

Only one of these methods can be used per Task.

## License

Expand Down
29 changes: 26 additions & 3 deletions acp/api/v1alpha1/task_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,39 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// Message roles
const (
MessageRoleSystem = "system"
MessageRoleUser = "user"
MessageRoleAssistant = "assistant"
MessageRoleTool = "tool"
)

// ValidMessageRoles provides a map of valid message roles for validation
var ValidMessageRoles = map[string]bool{
MessageRoleSystem: true,
MessageRoleUser: true,
MessageRoleAssistant: true,
MessageRoleTool: true,
}

// TaskSpec defines the desired state of Task
type TaskSpec struct {
// AgentRef references the agent that will execute this Task.
// +kubebuilder:validation:Required
AgentRef LocalObjectReference `json:"agentRef"`

// UserMessage is the message to send to the agent.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
UserMessage string `json:"userMessage"`
// If provided, userMessage will be used and contextWindow must be empty.
// +optional
UserMessage string `json:"userMessage,omitempty"`

// ContextWindow provides the initial conversation context when creating a Task.
// If provided, contextWindow will be used and userMessage must be empty.
// This will be copied to status.ContextWindow, which is the source of truth
// for the ongoing conversation.
// +optional
ContextWindow []Message `json:"contextWindow,omitempty"`
}

// Message represents a single message in the conversation
Expand Down
9 changes: 8 additions & 1 deletion acp/api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 71 additions & 3 deletions acp/config/crd/bases/acp.humanlayer.dev_tasks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,81 @@ spec:
required:
- name
type: object
contextWindow:
description: |-
ContextWindow provides the initial conversation context when creating a Task.
If provided, contextWindow will be used and userMessage must be empty.
This will be copied to status.ContextWindow, which is the source of truth
for the ongoing conversation.
items:
description: Message represents a single message in the conversation
properties:
content:
description: Content is the message content
type: string
name:
description: Name is the name of the tool that was called
type: string
role:
description: Role is the role of the message sender (system,
user, assistant, tool)
enum:
- system
- user
- assistant
- tool
type: string
toolCallId:
description: ToolCallID is the unique identifier for this tool
call
type: string
toolCalls:
description: ToolCalls contains any tool calls requested by
this message
items:
description: ToolCall represents a request to call a tool
properties:
function:
description: Function contains the details of the function
to call
properties:
arguments:
description: Arguments contains the arguments to pass
to the function in JSON format
type: string
name:
description: Name is the name of the function to call
type: string
required:
- arguments
- name
type: object
id:
description: ID is the unique identifier for this tool
call
type: string
type:
description: Type indicates the type of tool call. Currently
only "function" is supported.
type: string
required:
- function
- id
- type
type: object
type: array
required:
- content
- role
type: object
type: array
userMessage:
description: UserMessage is the message to send to the agent.
minLength: 1
description: |-
UserMessage is the message to send to the agent.
If provided, userMessage will be used and contextWindow must be empty.
type: string
required:
- agentRef
- userMessage
type: object
status:
description: TaskStatus defines the observed state of Task
Expand Down
15 changes: 14 additions & 1 deletion acp/config/example-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,21 @@ See the samples file for examples of all supported providers.

- **agentRef:**
- References an existing Agent (e.g. `"calculator-agent"`)
- **message:**
- **userMessage:**
- The task prompt or request (e.g. `"What is 2 + 2?"`)
- Used for single-turn queries
- Cannot be used with `contextWindow`

**OR**

- **contextWindow:**
- Array of messages representing an initial conversation
- Used for multi-turn conversations or continuing previous conversations
- Must contain at least one user message
- Cannot be used with `userMessage`
- If no system message is included, the agent's system message will be prepended
Copy link
Contributor

@dexhorthy dexhorthy Apr 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it have to end with a user message?

if i POST a contextWindow with tool calls, do the tool calls get created or are they assumed to exist?

not suggesting any new implementation, just clarity in the docs what should be expected. It can have a "for now" on it for now

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated docs to reflect current status:

  1. Tool calls/messages in the contextWindow are NOT executed - they're treated purely as conversation history
  2. The last message can be any valid role (user, assistant, tool) - the controller will send the entire context to the
    LLM
  3. The UserMsgPreview comes from the last user message, not necessarily the last message in the context

A potential improvement might be to require it to end with a user message. I think openai might still respond properly if most recent is a presumed-done "tool" but the api might get upset if it's another assistant.


**Sample with contextWindow:** `config/samples/acp_v1alpha1_context_window_task.yaml`

---

Expand Down
16 changes: 16 additions & 0 deletions acp/config/samples/acp_v1alpha1_context_window_task.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
apiVersion: acp.humanlayer.dev/v1alpha1
kind: Task
metadata:
name: context-window-example
spec:
agentRef:
name: web-fetch-agent
contextWindow:
- role: system
content: "You are a helpful web assistant."
- role: user
content: "Can you search the web for info on climate change?"
- role: assistant
content: "I'll help you research climate change. What specific aspects interest you?"
- role: user
content: "Tell me about recent developments in renewable energy."
8 changes: 6 additions & 2 deletions acp/docs/crd-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,16 @@ The Task CRD represents a task instance.
| Field | Type | Description | Required |
|-------|------|-------------|----------|
| `agentRef` | LocalObjectReference | Reference to the agent to execute the task | Yes |
| `userMessage` | string | Message to send to the agent | Yes |
| `userMessage` | string | Message to send to the agent | No* |
| `contextWindow` | []Message | Initial conversation context with multiple messages | No* |

*Either `userMessage` or `contextWindow` must be specified, but not both.

### Status Fields

| Field | Type | Description |
|-------|------|-------------|
| `phase` | string | Current phase of execution |
| `phaseHistory` | []PhaseTransition | History of phase transitions |
| `contextWindow` | []Message | The conversation context |
| `contextWindow` | []Message | The conversation context |
| `userMsgPreview` | string | Preview of the user message or last user message in contextWindow |
82 changes: 46 additions & 36 deletions acp/internal/controller/task/task_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/humanlayer/agentcontrolplane/acp/internal/adapters"
"github.com/humanlayer/agentcontrolplane/acp/internal/llmclient"
"github.com/humanlayer/agentcontrolplane/acp/internal/mcpmanager"
"github.com/humanlayer/agentcontrolplane/acp/internal/validation"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
Expand Down Expand Up @@ -152,54 +153,63 @@ func (r *TaskReconciler) validateTaskAndAgent(ctx context.Context, task *acp.Tas
return &agent, ctrl.Result{}, nil
}

// Helper function for setting validation errors
func (r *TaskReconciler) setValidationError(ctx context.Context, task *acp.Task, statusUpdate *acp.Task, err error) (ctrl.Result, error) {
logger := log.FromContext(ctx)
logger.Error(err, "Validation failed")
statusUpdate.Status.Ready = false
statusUpdate.Status.Status = acp.TaskStatusTypeError
statusUpdate.Status.Phase = acp.TaskPhaseFailed
statusUpdate.Status.StatusDetail = err.Error()
statusUpdate.Status.Error = err.Error()
r.recorder.Event(task, corev1.EventTypeWarning, "ValidationFailed", err.Error())
if updateErr := r.Status().Update(ctx, statusUpdate); updateErr != nil {
logger.Error(updateErr, "Failed to update Task status")
return ctrl.Result{}, updateErr
}
return ctrl.Result{}, err
}

// prepareForLLM sets up the initial state of a Task with the correct context and phase
func (r *TaskReconciler) prepareForLLM(ctx context.Context, task *acp.Task, statusUpdate *acp.Task, agent *acp.Agent) (ctrl.Result, error) {
logger := log.FromContext(ctx)

// If we're in Initializing or Pending phase, transition to ReadyForLLM
if statusUpdate.Status.Phase == acp.TaskPhaseInitializing || statusUpdate.Status.Phase == acp.TaskPhasePending {
statusUpdate.Status.Phase = acp.TaskPhaseReadyForLLM
statusUpdate.Status.Ready = true

if task.Spec.UserMessage == "" {
err := fmt.Errorf("userMessage is required")
logger.Error(err, "Missing message")
statusUpdate.Status.Ready = false
statusUpdate.Status.Status = acp.TaskStatusTypeError
statusUpdate.Status.Phase = acp.TaskPhaseFailed
statusUpdate.Status.StatusDetail = err.Error()
statusUpdate.Status.Error = err.Error()
r.recorder.Event(task, corev1.EventTypeWarning, "ValidationFailed", err.Error())
if updateErr := r.Status().Update(ctx, statusUpdate); updateErr != nil {
logger.Error(updateErr, "Failed to update Task status")
return ctrl.Result{}, updateErr
}
return ctrl.Result{}, err
if err := validation.ValidateTaskMessageInput(task.Spec.UserMessage, task.Spec.ContextWindow); err != nil {
return r.setValidationError(ctx, task, statusUpdate, err)
}

// Set the UserMsgPreview - truncate to 50 chars if needed
preview := task.Spec.UserMessage
if len(preview) > 50 {
preview = preview[:47] + "..."
var initialContextWindow []acp.Message
if len(task.Spec.ContextWindow) > 0 {
initialContextWindow = append([]acp.Message{}, task.Spec.ContextWindow...)
hasSystemMessage := false
for _, msg := range initialContextWindow {
if msg.Role == acp.MessageRoleSystem {
hasSystemMessage = true
break
}
}
if !hasSystemMessage {
initialContextWindow = append([]acp.Message{
{Role: acp.MessageRoleSystem, Content: agent.Spec.System},
}, initialContextWindow...)
}
} else {
initialContextWindow = []acp.Message{
{Role: acp.MessageRoleSystem, Content: agent.Spec.System},
{Role: acp.MessageRoleUser, Content: task.Spec.UserMessage},
}
}
statusUpdate.Status.UserMsgPreview = preview

// Set up the context window
statusUpdate.Status.ContextWindow = []acp.Message{
{
Role: "system",
Content: agent.Spec.System,
},
{
Role: "user",
Content: task.Spec.UserMessage,
},
}
statusUpdate.Status.UserMsgPreview = validation.GetUserMessagePreview(task.Spec.UserMessage, task.Spec.ContextWindow)
statusUpdate.Status.ContextWindow = initialContextWindow
statusUpdate.Status.Phase = acp.TaskPhaseReadyForLLM
statusUpdate.Status.Ready = true
statusUpdate.Status.Status = acp.TaskStatusTypeReady
statusUpdate.Status.StatusDetail = "Ready to send to LLM"
statusUpdate.Status.Error = "" // Clear previous error
r.recorder.Event(task, corev1.EventTypeNormal, "ValidationSucceeded", "Task validation succeeded")
statusUpdate.Status.Error = ""

r.recorder.Event(task, corev1.EventTypeNormal, "ValidationSucceeded", "Task validation succeeded")
if err := r.Status().Update(ctx, statusUpdate); err != nil {
logger.Error(err, "Failed to update Task status")
return ctrl.Result{}, err
Expand Down
Loading