From 8068dca4641393c03aec12253e7c1195943727f1 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Thu, 9 Jul 2026 03:16:37 +0000 Subject: feat(agentchannel,executor,agentloop): expose acceptance_criteria on spawn_subtask --- internal/executor/agentmcp.go | 26 +++++++++-------- internal/executor/channel.go | 19 +++++++------ internal/executor/channel_test.go | 59 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 21 deletions(-) (limited to 'internal/executor') diff --git a/internal/executor/agentmcp.go b/internal/executor/agentmcp.go index 35781e2..a9a195d 100644 --- a/internal/executor/agentmcp.go +++ b/internal/executor/agentmcp.go @@ -64,12 +64,13 @@ type reportSummaryInput struct { } type spawnSubtaskInput struct { - Name string `json:"name" jsonschema:"short descriptive name for the subtask"` - Instructions string `json:"instructions" jsonschema:"complete instructions for the subtask agent"` - Model string `json:"model,omitempty" jsonschema:"optional model override, e.g. sonnet or opus"` - MaxBudgetUSD float64 `json:"max_budget_usd,omitempty" jsonschema:"optional budget cap in USD"` - Role string `json:"role,omitempty" jsonschema:"optional role name to dispatch the subtask through instead of a fixed model (e.g. an evaluator role); when set, model is ignored and the role's escalation ladder picks the provider/model"` - DependsOn []string `json:"depends_on,omitempty" jsonschema:"optional list of sibling subtask IDs (returned by prior spawn_subtask calls in this same decomposition) this subtask must wait for before it can run"` + Name string `json:"name" jsonschema:"short descriptive name for the subtask"` + Instructions string `json:"instructions" jsonschema:"complete instructions for the subtask agent"` + Model string `json:"model,omitempty" jsonschema:"optional model override, e.g. sonnet or opus"` + MaxBudgetUSD float64 `json:"max_budget_usd,omitempty" jsonschema:"optional budget cap in USD"` + Role string `json:"role,omitempty" jsonschema:"optional role name to dispatch the subtask through instead of a fixed model (e.g. an evaluator role); when set, model is ignored and the role's escalation ladder picks the provider/model"` + DependsOn []string `json:"depends_on,omitempty" jsonschema:"optional list of sibling subtask IDs (returned by prior spawn_subtask calls in this same decomposition) this subtask must wait for before it can run"` + AcceptanceCriteria []string `json:"acceptance_criteria,omitempty" jsonschema:"optional list of concrete criteria this subtask's work must satisfy"` } type recordProgressInput struct { @@ -153,12 +154,13 @@ func newAgentServer(ch AgentChannel) *mcp.Server { Description: "Create a child task to be executed separately. Use this to break large work into focused pieces, then finish your turn.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in spawnSubtaskInput) (*mcp.CallToolResult, any, error) { id, err := ch.SpawnSubtask(ctx, SubtaskSpec{ - Name: in.Name, - Instructions: in.Instructions, - Model: in.Model, - MaxBudgetUSD: in.MaxBudgetUSD, - Role: in.Role, - DependsOn: in.DependsOn, + Name: in.Name, + Instructions: in.Instructions, + Model: in.Model, + MaxBudgetUSD: in.MaxBudgetUSD, + Role: in.Role, + DependsOn: in.DependsOn, + AcceptanceCriteria: in.AcceptanceCriteria, }) if err != nil { return nil, nil, err diff --git a/internal/executor/channel.go b/internal/executor/channel.go index 55da7b1..e815b00 100644 --- a/internal/executor/channel.go +++ b/internal/executor/channel.go @@ -151,15 +151,16 @@ func (c *storeChannel) SpawnSubtask(_ context.Context, spec SubtaskSpec) (string agent.Model = spec.Model } child := &task.Task{ - ID: uuid.NewString(), - Name: spec.Name, - ParentTaskID: c.taskID, - Agent: agent, - Priority: task.PriorityNormal, - DependsOn: spec.DependsOn, - State: task.StatePending, - CreatedAt: now, - UpdatedAt: now, + ID: uuid.NewString(), + Name: spec.Name, + ParentTaskID: c.taskID, + Agent: agent, + Priority: task.PriorityNormal, + DependsOn: spec.DependsOn, + AcceptanceCriteria: spec.AcceptanceCriteria, + State: task.StatePending, + CreatedAt: now, + UpdatedAt: now, } if err := c.store.CreateTask(child); err != nil { return "", err diff --git a/internal/executor/channel_test.go b/internal/executor/channel_test.go index 23f0661..a2e7c70 100644 --- a/internal/executor/channel_test.go +++ b/internal/executor/channel_test.go @@ -338,6 +338,65 @@ func TestStoreChannel_SpawnSubtask_DependsOnDefaultsEmpty(t *testing.T) { } } +// TestStoreChannel_SpawnSubtask_SetsAcceptanceCriteria proves a caller can +// state what a spawned subtask's work must satisfy. +func TestStoreChannel_SpawnSubtask_SetsAcceptanceCriteria(t *testing.T) { + store := &fakeChannelStore{} + ch := newStoreChannel(store, "parent-task-1") + + id, err := ch.SpawnSubtask(context.Background(), agentchannel.SubtaskSpec{ + Name: "step 1", + Instructions: "do the thing", + AcceptanceCriteria: []string{"tests pass", "no new lint warnings"}, + }) + if err != nil { + t.Fatalf("spawn subtask: %v", err) + } + + var got *task.Task + for _, ct := range store.createdTasks { + if ct.ID == id { + got = ct + } + } + if got == nil { + t.Fatal("subtask not found in store.createdTasks") + } + if len(got.AcceptanceCriteria) != 2 || got.AcceptanceCriteria[0] != "tests pass" || got.AcceptanceCriteria[1] != "no new lint warnings" { + t.Errorf("AcceptanceCriteria: want [tests pass, no new lint warnings], got %+v", got.AcceptanceCriteria) + } +} + +// TestStoreChannel_SpawnSubtask_AcceptanceCriteriaDefaultsEmpty proves the +// backward-compatible default: a caller that never sets AcceptanceCriteria +// (every pre-existing caller) gets a child task with none, exactly as before +// this change. +func TestStoreChannel_SpawnSubtask_AcceptanceCriteriaDefaultsEmpty(t *testing.T) { + store := &fakeChannelStore{} + ch := newStoreChannel(store, "parent-task-1") + + id, err := ch.SpawnSubtask(context.Background(), agentchannel.SubtaskSpec{ + Name: "solo step", + Instructions: "do the thing", + }) + if err != nil { + t.Fatalf("spawn subtask: %v", err) + } + + var got *task.Task + for _, ct := range store.createdTasks { + if ct.ID == id { + got = ct + } + } + if got == nil { + t.Fatal("subtask not found in store.createdTasks") + } + if len(got.AcceptanceCriteria) != 0 { + t.Errorf("expected empty AcceptanceCriteria by default, got %v", got.AcceptanceCriteria) + } +} + func TestStoreChannel_RecordProgress_EmitsAgentMessage(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "task-1") -- cgit v1.2.3