summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-07-09 03:16:37 +0000
committerClaudomator Agent <agent@claudomator>2026-07-09 03:16:37 +0000
commit8068dca4641393c03aec12253e7c1195943727f1 (patch)
tree227ff5cc6d57b624fb2d8efee0e8ac21fd3bc662
parent5ea8f50b0c43a23af6e65bce1fe057a355cb1929 (diff)
feat(agentchannel,executor,agentloop): expose acceptance_criteria on spawn_subtask
-rw-r--r--internal/agentchannel/agentchannel.go7
-rw-r--r--internal/agentloop/tools.go39
-rw-r--r--internal/executor/agentmcp.go26
-rw-r--r--internal/executor/channel.go19
-rw-r--r--internal/executor/channel_test.go59
5 files changed, 111 insertions, 39 deletions
diff --git a/internal/agentchannel/agentchannel.go b/internal/agentchannel/agentchannel.go
index 433b762..64ac664 100644
--- a/internal/agentchannel/agentchannel.go
+++ b/internal/agentchannel/agentchannel.go
@@ -52,6 +52,13 @@ type SubtaskSpec struct {
// (the default) preserves today's behavior: every spawned subtask is
// structurally independent/parallel.
DependsOn []string
+ // AcceptanceCriteria, when non-empty, states what this subtask's work
+ // must satisfy — set directly on the created child's
+ // task.Task.AcceptanceCriteria. Empty (the default) leaves the child with
+ // no criteria of its own; a later phase's arbitrated-review
+ // generalization falls back to the enclosing story's AcceptanceCriteria
+ // in that case.
+ AcceptanceCriteria []string
}
// EpicProposal describes an epic a discovery/planner-role agent wants to
diff --git a/internal/agentloop/tools.go b/internal/agentloop/tools.go
index 2bdf0e0..7f98705 100644
--- a/internal/agentloop/tools.go
+++ b/internal/agentloop/tools.go
@@ -51,12 +51,13 @@ func agentToolSpecs() []provider.ToolSpec {
ParametersJSONSchema: map[string]any{
"type": "object",
"properties": map[string]any{
- "name": strProp("short descriptive name for the subtask"),
- "instructions": strProp("complete instructions for the subtask agent"),
- "model": strProp("optional model override"),
- "max_budget_usd": map[string]any{"type": "number", "description": "optional budget cap in USD"},
- "role": strProp("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"),
- "depends_on": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "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": strProp("short descriptive name for the subtask"),
+ "instructions": strProp("complete instructions for the subtask agent"),
+ "model": strProp("optional model override"),
+ "max_budget_usd": map[string]any{"type": "number", "description": "optional budget cap in USD"},
+ "role": strProp("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"),
+ "depends_on": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "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"},
+ "acceptance_criteria": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "optional list of concrete criteria this subtask's work must satisfy"},
},
"required": []string{"name", "instructions"},
},
@@ -218,21 +219,23 @@ func (l *Loop) dispatchTool(ctx context.Context, name, argsJSON string) (result
case "spawn_subtask":
var a struct {
- Name string `json:"name"`
- Instructions string `json:"instructions"`
- Model string `json:"model"`
- MaxBudgetUSD float64 `json:"max_budget_usd"`
- Role string `json:"role"`
- DependsOn []string `json:"depends_on"`
+ Name string `json:"name"`
+ Instructions string `json:"instructions"`
+ Model string `json:"model"`
+ MaxBudgetUSD float64 `json:"max_budget_usd"`
+ Role string `json:"role"`
+ DependsOn []string `json:"depends_on"`
+ AcceptanceCriteria []string `json:"acceptance_criteria"`
}
_ = json.Unmarshal([]byte(argsJSON), &a)
id, ssErr := l.Channel.SpawnSubtask(ctx, agentchannel.SubtaskSpec{
- Name: a.Name,
- Instructions: a.Instructions,
- Model: a.Model,
- MaxBudgetUSD: a.MaxBudgetUSD,
- Role: a.Role,
- DependsOn: a.DependsOn,
+ Name: a.Name,
+ Instructions: a.Instructions,
+ Model: a.Model,
+ MaxBudgetUSD: a.MaxBudgetUSD,
+ Role: a.Role,
+ DependsOn: a.DependsOn,
+ AcceptanceCriteria: a.AcceptanceCriteria,
})
if ssErr != nil {
return "", false, ssErr
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")