@namzu/sdk 8.0.0 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  3. package/dist/agents/SupervisorAgent.js +14 -6
  4. package/dist/agents/SupervisorAgent.js.map +1 -1
  5. package/dist/agents/__tests__/a-policy-nobody-can-select.test.d.ts +2 -0
  6. package/dist/agents/__tests__/a-policy-nobody-can-select.test.d.ts.map +1 -0
  7. package/dist/agents/__tests__/a-policy-nobody-can-select.test.js +177 -0
  8. package/dist/agents/__tests__/a-policy-nobody-can-select.test.js.map +1 -0
  9. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.d.ts +2 -0
  10. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.d.ts.map +1 -0
  11. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.js +114 -0
  12. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.js.map +1 -0
  13. package/dist/gateway/local.d.ts.map +1 -1
  14. package/dist/gateway/local.js +19 -6
  15. package/dist/gateway/local.js.map +1 -1
  16. package/dist/manager/agent/__tests__/lifecycle.test.js +56 -0
  17. package/dist/manager/agent/__tests__/lifecycle.test.js.map +1 -1
  18. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  19. package/dist/manager/agent/lifecycle.js +62 -33
  20. package/dist/manager/agent/lifecycle.js.map +1 -1
  21. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.d.ts +2 -0
  22. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.d.ts.map +1 -0
  23. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.js +73 -0
  24. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.js.map +1 -0
  25. package/dist/manager/plan/lifecycle.d.ts +48 -0
  26. package/dist/manager/plan/lifecycle.d.ts.map +1 -1
  27. package/dist/manager/plan/lifecycle.js +56 -0
  28. package/dist/manager/plan/lifecycle.js.map +1 -1
  29. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.d.ts +2 -0
  30. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.d.ts.map +1 -0
  31. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.js +62 -0
  32. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.js.map +1 -0
  33. package/dist/runtime/query/events.d.ts.map +1 -1
  34. package/dist/runtime/query/events.js +5 -0
  35. package/dist/runtime/query/events.js.map +1 -1
  36. package/dist/runtime/query/index.d.ts +0 -1
  37. package/dist/runtime/query/index.d.ts.map +1 -1
  38. package/dist/runtime/query/index.js +0 -1
  39. package/dist/runtime/query/index.js.map +1 -1
  40. package/dist/runtime/query/iteration/phases/context.d.ts +0 -17
  41. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  42. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  43. package/dist/store/task/disk.d.ts.map +1 -1
  44. package/dist/store/task/disk.js +14 -0
  45. package/dist/store/task/disk.js.map +1 -1
  46. package/dist/store/task/memory.d.ts.map +1 -1
  47. package/dist/store/task/memory.js +14 -0
  48. package/dist/store/task/memory.js.map +1 -1
  49. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.d.ts +2 -0
  50. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.d.ts.map +1 -0
  51. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.js +101 -0
  52. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.js.map +1 -0
  53. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.d.ts +2 -0
  54. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.d.ts.map +1 -0
  55. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.js +99 -0
  56. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.js.map +1 -0
  57. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  58. package/dist/tools/coordinator/agent.js +17 -23
  59. package/dist/tools/coordinator/agent.js.map +1 -1
  60. package/dist/tools/coordinator/index.d.ts.map +1 -1
  61. package/dist/tools/coordinator/index.js +33 -3
  62. package/dist/tools/coordinator/index.js.map +1 -1
  63. package/dist/tools/coordinator/outcome.d.ts +60 -0
  64. package/dist/tools/coordinator/outcome.d.ts.map +1 -0
  65. package/dist/tools/coordinator/outcome.js +68 -0
  66. package/dist/tools/coordinator/outcome.js.map +1 -0
  67. package/dist/types/agent/gateway.d.ts +19 -2
  68. package/dist/types/agent/gateway.d.ts.map +1 -1
  69. package/dist/types/agent/supervisor.d.ts +38 -1
  70. package/dist/types/agent/supervisor.d.ts.map +1 -1
  71. package/dist/types/plan/index.d.ts +26 -0
  72. package/dist/types/plan/index.d.ts.map +1 -1
  73. package/dist/types/run/events.d.ts +17 -0
  74. package/dist/types/run/events.d.ts.map +1 -1
  75. package/dist/types/run/events.js.map +1 -1
  76. package/dist/types/task/index.d.ts +19 -1
  77. package/dist/types/task/index.d.ts.map +1 -1
  78. package/dist/types/task/index.js +10 -1
  79. package/dist/types/task/index.js.map +1 -1
  80. package/package.json +1 -1
  81. package/src/agents/SupervisorAgent.ts +15 -9
  82. package/src/agents/__tests__/a-policy-nobody-can-select.test.ts +202 -0
  83. package/src/gateway/__tests__/a-knob-nobody-can-turn.test.ts +139 -0
  84. package/src/gateway/local.ts +19 -7
  85. package/src/manager/agent/__tests__/lifecycle.test.ts +74 -0
  86. package/src/manager/agent/lifecycle.ts +72 -42
  87. package/src/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.ts +88 -0
  88. package/src/manager/plan/lifecycle.ts +61 -0
  89. package/src/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.ts +79 -0
  90. package/src/runtime/query/events.ts +5 -0
  91. package/src/runtime/query/index.ts +0 -6
  92. package/src/runtime/query/iteration/phases/context.ts +0 -19
  93. package/src/store/task/disk.ts +15 -0
  94. package/src/store/task/memory.ts +15 -0
  95. package/src/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.ts +133 -0
  96. package/src/tools/coordinator/__tests__/the-plan-a-human-approves.test.ts +121 -0
  97. package/src/tools/coordinator/agent.ts +17 -25
  98. package/src/tools/coordinator/index.ts +33 -3
  99. package/src/tools/coordinator/outcome.ts +70 -0
  100. package/src/types/agent/gateway.ts +19 -2
  101. package/src/types/agent/supervisor.ts +40 -1
  102. package/src/types/plan/index.ts +29 -0
  103. package/src/types/run/events.ts +17 -0
  104. package/src/types/task/index.ts +22 -3
@@ -121,6 +121,10 @@ export class EventTranslator {
121
121
  taskId: task.id,
122
122
  subject: task.subject,
123
123
  status: task.status,
124
+ // Absent rather than empty: a reader must be able to tell
125
+ // "depends on nothing" from an emitter that predates these.
126
+ ...(task.blockedBy.length > 0 ? { blockedBy: task.blockedBy } : {}),
127
+ ...(task.owner !== undefined ? { owner: task.owner } : {}),
124
128
  })
125
129
  break
126
130
  case 'task.updated':
@@ -133,6 +137,7 @@ export class EventTranslator {
133
137
  subject: task.subject,
134
138
  status: task.status,
135
139
  owner: task.owner,
140
+ ...(task.blockedBy.length > 0 ? { blockedBy: task.blockedBy } : {}),
136
141
  })
137
142
  break
138
143
  default: {
@@ -420,11 +420,6 @@ export interface QueryParams {
420
420
  */
421
421
  completionInbox?: import('../../gateway/completion-inbox.js').CompletionInbox
422
422
 
423
- launchedTasks?: Map<
424
- import('../../types/ids/index.js').TaskId,
425
- import('./iteration/phases/context.js').LaunchedTaskMeta
426
- >
427
-
428
423
  onContextCreated?: (ctx: {
429
424
  planManager: import('../../manager/plan/lifecycle.js').PlanManager
430
425
  }) => void
@@ -854,7 +849,6 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
854
849
  taskGateway: params.taskGateway,
855
850
  completionInbox: params.completionInbox,
856
851
  taskStore: params.taskStore,
857
- launchedTasks: params.launchedTasks ?? new Map(),
858
852
  // Run-scoped. An approval is a statement about this run's work;
859
853
  // carrying one into a later run would be reuse nobody agreed to.
860
854
  toolGrants: new ToolGrantSet(),
@@ -14,7 +14,6 @@ import type {
14
14
  IterationCheckpoint,
15
15
  ResumeHandler,
16
16
  } from '../../../../types/hitl/index.js'
17
- import type { TaskId } from '../../../../types/ids/index.js'
18
17
  import type { LLMProvider } from '../../../../types/provider/index.js'
19
18
  import type { TaskRouterConfig } from '../../../../types/router/index.js'
20
19
  import type { ReviewAnswer } from '../../../../types/run/answer-review.js'
@@ -36,22 +35,6 @@ import type { GuardCoordinator } from '../../guard.js'
36
35
  import type { SteeringChannel } from '../../steering.js'
37
36
  import type { ToolGrantSet } from '../../tool-grants.js'
38
37
 
39
- export interface LaunchedTaskMeta {
40
- readonly agentId: string
41
- readonly description: string
42
- readonly planTaskId?: string
43
- /**
44
- * The `tool_use_id` of the assistant `create_task` block that
45
- * spawned this background task. Required to emit the canonical
46
- * `tool_result` content block when the task completes — without
47
- * it we'd fall back to the legacy synthetic-user-message inject
48
- * (see ses_009-task-notification-envelope). Optional because
49
- * older call paths that don't thread `ToolContext.toolUseId`
50
- * still publish the meta without it.
51
- */
52
- readonly originalToolUseId?: string
53
- }
54
-
55
38
  export interface IterationContext {
56
39
  readonly provider: LLMProvider
57
40
  /**
@@ -115,8 +98,6 @@ export interface IterationContext {
115
98
 
116
99
  readonly taskStore?: TaskStore
117
100
 
118
- readonly launchedTasks: Map<TaskId, LaunchedTaskMeta>
119
-
120
101
  /**
121
102
  * Approvals a human granted earlier in this run, at a scope they chose.
122
103
  *
@@ -35,10 +35,16 @@ export interface DiskTaskStoreConfig {
35
35
  logger?: Logger
36
36
  }
37
37
 
38
+ // `failed` ranks alongside `completed` rather than after it: both are
39
+ // terminal, and neither may transition to the other. Ranking it higher would
40
+ // admit completed -> failed, which would let a settled unit be reopened as a
41
+ // failure; ranking it lower would forbid in_progress -> failed, which is the
42
+ // transition this status exists for.
38
43
  const STATUS_ORDER: Record<TaskStatus, number> = {
39
44
  pending: 0,
40
45
  in_progress: 1,
41
46
  completed: 2,
47
+ failed: 2,
42
48
  }
43
49
 
44
50
  function isForwardTransition(from: TaskStatus, to: TaskStatus): boolean {
@@ -393,7 +399,16 @@ export class DiskTaskStore implements TaskStore {
393
399
  }
394
400
  if (!mutated) {
395
401
  this.log.debug('block(): edge already exists', { blockerId, blockedId })
402
+ return
396
403
  }
404
+
405
+ // Announce BOTH ends, and only when something actually changed. The
406
+ // edge was written and nothing said so, so the graph was observable
407
+ // only by polling — a listener saw a unit created and never learned
408
+ // that something now waits on it.
409
+ const now = Date.now()
410
+ this.emit({ type: 'task.updated', taskId: blockerId, task: blocker, timestamp: now })
411
+ this.emit({ type: 'task.updated', taskId: blockedId, task: blocked, timestamp: now })
397
412
  })
398
413
  }
399
414
 
@@ -10,10 +10,16 @@ import type {
10
10
  } from '../../types/task/index.js'
11
11
  import { generateTaskId } from '../../utils/id.js'
12
12
 
13
+ // `failed` ranks alongside `completed` rather than after it: both are
14
+ // terminal, and neither may transition to the other. Ranking it higher would
15
+ // admit completed -> failed, which would let a settled unit be reopened as a
16
+ // failure; ranking it lower would forbid in_progress -> failed, which is the
17
+ // transition this status exists for.
13
18
  const STATUS_ORDER: Record<TaskStatus, number> = {
14
19
  pending: 0,
15
20
  in_progress: 1,
16
21
  completed: 2,
22
+ failed: 2,
17
23
  }
18
24
 
19
25
  function isForwardTransition(from: TaskStatus, to: TaskStatus): boolean {
@@ -177,6 +183,15 @@ export class InMemoryTaskStore implements TaskStore {
177
183
  if (!blocked.blockedBy.includes(blockerId)) {
178
184
  blocked.blockedBy.push(blockerId)
179
185
  }
186
+
187
+ // Announce BOTH ends. The edge was written and nothing said so, which
188
+ // left the graph observable only by polling: a listener saw a unit
189
+ // created and never learned that something now waits on it. Both sides
190
+ // changed, so both are announced — a host tracking only one would draw
191
+ // half the edge.
192
+ const now = Date.now()
193
+ this.emit({ type: 'task.updated', taskId: blockerId, task: blocker, timestamp: now })
194
+ this.emit({ type: 'task.updated', taskId: blockedId, task: blocked, timestamp: now })
180
195
  }
181
196
 
182
197
  async reset(): Promise<void> {
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { TaskGateway, TaskHandle } from '../../../types/agent/gateway.js'
4
+ import type { TaskId } from '../../../types/ids/index.js'
5
+ import type { ToolDefinition } from '../../../types/tool/index.js'
6
+ import { buildCoordinatorTools } from '../index.js'
7
+ import { failureLabel, taskSucceeded } from '../outcome.js'
8
+
9
+ /**
10
+ * A worker that ran and failed was reported to the model as an answer.
11
+ *
12
+ * Two layers can disagree. `finalizeChild` always calls `markCompleted`, so the
13
+ * gateway's `state` is `'completed'` for a child whose run returned
14
+ * `status: 'failed'` — and `create_task` asked only that layer. The model then
15
+ * read the failure text as a result, the tool result carried `isError: false`,
16
+ * and the plan task was written closed as though the work had been done.
17
+ *
18
+ * The correct predicate existed twenty lines away in the canonical `Agent`
19
+ * tool, put there because a review caught it on that site. Nothing carried the
20
+ * answer to the other one. So this file tests the shared predicate, and the
21
+ * predicate is shared so there is no longer a second place to forget.
22
+ */
23
+
24
+ const handle = (
25
+ state: TaskHandle['state'],
26
+ status?: string,
27
+ ): Pick<TaskHandle, 'state' | 'result'> =>
28
+ ({ state, result: status === undefined ? undefined : { status } }) as Pick<
29
+ TaskHandle,
30
+ 'state' | 'result'
31
+ >
32
+
33
+ describe('success needs both authorities to agree', () => {
34
+ it('refuses a child the gateway called complete but whose run failed', () => {
35
+ // The exact shape the kernel produces: markCompleted was called, and
36
+ // the run underneath it did not succeed.
37
+ expect(taskSucceeded(handle('completed', 'failed'))).toBe(false)
38
+ })
39
+
40
+ it('accepts a child both layers agree on', () => {
41
+ expect(taskSucceeded(handle('completed', 'completed'))).toBe(true)
42
+ })
43
+
44
+ it('accepts a gateway that reports no run status at all', () => {
45
+ // A host gateway need not surface a run status. Treating its absence as
46
+ // failure would break every such gateway, so absence means "this layer
47
+ // has no opinion" rather than "it went wrong".
48
+ expect(taskSucceeded(handle('completed'))).toBe(true)
49
+ })
50
+
51
+ it('refuses a child that never reached a completed state', () => {
52
+ expect(taskSucceeded(handle('failed', 'completed'))).toBe(false)
53
+ expect(taskSucceeded(handle('canceled'))).toBe(false)
54
+ })
55
+ })
56
+
57
+ describe('the failure is named by whichever layer reported it', () => {
58
+ it('uses the task state when the task itself did not complete', () => {
59
+ // "failed" would lose the distinction a reader needs: a cancelled task
60
+ // and a task whose run errored call for different next moves.
61
+ expect(failureLabel(handle('canceled', 'completed'))).toBe('canceled')
62
+ })
63
+
64
+ it('uses the run status when the task completed but the run did not', () => {
65
+ expect(failureLabel(handle('completed', 'failed'))).toBe('failed')
66
+ })
67
+
68
+ it('falls back to a plain word when neither layer said anything useful', () => {
69
+ expect(failureLabel(handle('completed'))).toBe('failed')
70
+ })
71
+ })
72
+
73
+ describe('create_task itself reaches the predicate', () => {
74
+ /**
75
+ * The unit tests above prove the predicate is right. They would all pass
76
+ * with `create_task` still asking only the gateway — which is exactly the
77
+ * state that shipped, with the correct version sitting twenty lines away in
78
+ * a sibling tool.
79
+ *
80
+ * So this drives the tool.
81
+ */
82
+ function toolFor(handle: TaskHandle): ToolDefinition {
83
+ const gateway = {
84
+ createTask: async () => ({ ...handle, state: 'running' }),
85
+ waitForTask: async () => handle,
86
+ getTask: () => handle,
87
+ listTasks: () => [handle],
88
+ cancelTask: () => undefined,
89
+ continueTask: async () => undefined,
90
+ onTaskCompleted: () => () => undefined,
91
+ } as unknown as TaskGateway
92
+
93
+ const tools = buildCoordinatorTools({
94
+ gateway,
95
+ workingDirectory: '/tmp/test',
96
+ allowedAgentIds: ['reviewer'],
97
+ })
98
+ const createTask = tools.find((t) => t.name === 'create_task')
99
+ if (!createTask) throw new Error('create_task was not built')
100
+ return createTask
101
+ }
102
+
103
+ const settled = (status: string): TaskHandle =>
104
+ ({
105
+ taskId: 'tsk_1' as TaskId,
106
+ agentId: 'reviewer',
107
+ // The kernel's own shape: markCompleted ran regardless of the run.
108
+ state: 'completed',
109
+ createdAt: 1_000,
110
+ completedAt: 2_000,
111
+ result: { status, result: 'the worker text', lastError: 'it blew up' },
112
+ }) as unknown as TaskHandle
113
+
114
+ it('reports a failed run as a failure', async () => {
115
+ const tool = toolFor(settled('failed'))
116
+ const result = await tool.execute(
117
+ { agent_id: 'reviewer', prompt: 'go', description: 'a task' },
118
+ { toolUseId: 'call_1' } as never,
119
+ )
120
+
121
+ expect(result.success, 'a failed worker was reported as an answer').toBe(false)
122
+ })
123
+
124
+ it('still reports a successful run as a success', async () => {
125
+ const tool = toolFor(settled('completed'))
126
+ const result = await tool.execute(
127
+ { agent_id: 'reviewer', prompt: 'go', description: 'a task' },
128
+ { toolUseId: 'call_1' } as never,
129
+ )
130
+
131
+ expect(result.success).toBe(true)
132
+ })
133
+ })
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { PlanManager } from '../../../manager/plan/lifecycle.js'
4
+ import type { TaskGateway } from '../../../types/agent/gateway.js'
5
+ import type { RunId } from '../../../types/ids/index.js'
6
+ import type { PlanApprovalRequest } from '../../../types/plan/index.js'
7
+ import type { ToolContext } from '../../../types/tool/index.js'
8
+ import { buildCoordinatorTools } from '../index.js'
9
+
10
+ /**
11
+ * The plan put in front of a human said a step delegates, never to whom.
12
+ *
13
+ * `approve_plan` asks the model for an `agent_id` per step — "which agent
14
+ * handles this" — and reduced the answer to a boolean: the step got
15
+ * `toolName: 'create_task'` when any agent was named and nothing when not. The
16
+ * name itself was dropped between the model saying it and the human being
17
+ * shown the plan.
18
+ *
19
+ * The approval is the one moment where that difference can still be acted on.
20
+ * Approving "delegate this step" is not the same as approving "delegate this
21
+ * step to the agent with shell access", and a reviewer who cannot see which
22
+ * agent was chosen cannot withhold approval from the wrong one.
23
+ */
24
+
25
+ const RUN = 'run_plan_approval' as RunId
26
+
27
+ function unusedGateway(): TaskGateway {
28
+ return {
29
+ async createTask() {
30
+ throw new Error('this test never launches')
31
+ },
32
+ async waitForTask() {
33
+ throw new Error('this test never waits')
34
+ },
35
+ async continueTask() {},
36
+ cancelTask() {},
37
+ getTask() {
38
+ return undefined
39
+ },
40
+ listTasks() {
41
+ return []
42
+ },
43
+ onTaskCompleted() {
44
+ return () => {}
45
+ },
46
+ }
47
+ }
48
+
49
+ function testToolContext(): ToolContext {
50
+ return {
51
+ runId: RUN,
52
+ workingDirectory: '/tmp/test',
53
+ abortSignal: new AbortController().signal,
54
+ env: {},
55
+ log: () => {},
56
+ }
57
+ }
58
+
59
+ /** Run `approve_plan` and hand back exactly what the approver was shown. */
60
+ async function whatTheApproverSaw(
61
+ steps: Array<{ description: string; agent_id?: string }>,
62
+ ): Promise<PlanApprovalRequest> {
63
+ let seen: PlanApprovalRequest | undefined
64
+ const pm = new PlanManager(RUN, async (request) => {
65
+ seen = request
66
+ return { approved: true }
67
+ })
68
+
69
+ const tools = buildCoordinatorTools({
70
+ gateway: unusedGateway(),
71
+ workingDirectory: '/tmp/test',
72
+ allowedAgentIds: ['researcher', 'shell-runner'],
73
+ getPlanManager: () => pm,
74
+ })
75
+
76
+ const approvePlan = tools.find((tool) => tool.name === 'approve_plan')
77
+ if (!approvePlan) throw new Error('approve_plan tool missing from coordinator builder')
78
+
79
+ await approvePlan.execute(
80
+ { title: 'Do the work', summary: 'A plan with delegated steps.', steps },
81
+ testToolContext(),
82
+ )
83
+
84
+ if (!seen) throw new Error('the approval handler was never called')
85
+ return seen
86
+ }
87
+
88
+ describe('the plan a human approves names the agent the model chose', () => {
89
+ it('carries the agent per step, not just that there is one', async () => {
90
+ const request = await whatTheApproverSaw([
91
+ { description: 'Gather the sources', agent_id: 'researcher' },
92
+ { description: 'Run the migration', agent_id: 'shell-runner' },
93
+ ])
94
+
95
+ expect(request.steps.map((s) => s.agentId)).toEqual(['researcher', 'shell-runner'])
96
+ })
97
+
98
+ it('distinguishes two delegated steps that used to look identical', async () => {
99
+ // The defect in the shape that matters: before this, both steps below
100
+ // reached the approver as `toolName: 'create_task'` and nothing else, so
101
+ // the one with shell access was indistinguishable from the one without.
102
+ const request = await whatTheApproverSaw([
103
+ { description: 'Read the docs', agent_id: 'researcher' },
104
+ { description: 'Delete the old rows', agent_id: 'shell-runner' },
105
+ ])
106
+
107
+ const [first, second] = request.steps
108
+ expect(first?.toolName).toBe(second?.toolName)
109
+ expect(first?.agentId).not.toBe(second?.agentId)
110
+ })
111
+
112
+ it('leaves an orchestrator-owned step unattributed', async () => {
113
+ // Omitting `agent_id` means the orchestrator does it itself. That has to
114
+ // stay distinguishable from delegation, so absent stays absent rather
115
+ // than becoming a placeholder name.
116
+ const request = await whatTheApproverSaw([{ description: 'Summarize what came back' }])
117
+
118
+ expect(request.steps[0]?.agentId).toBeUndefined()
119
+ expect(request.steps[0]?.toolName).toBeUndefined()
120
+ })
121
+ })
@@ -5,6 +5,7 @@ import type { TaskGateway } from '../../types/agent/gateway.js'
5
5
  import type { ToolDefinition } from '../../types/tool/index.js'
6
6
  import { defineTool } from '../defineTool.js'
7
7
  import { wrapUntrusted } from '../untrusted-envelope.js'
8
+ import { failureLabel, taskSucceeded } from './outcome.js'
8
9
 
9
10
  import type { TaskLaunchedCallback } from './index.js'
10
11
 
@@ -138,6 +139,13 @@ export function buildAgentTool(opts: AgentToolOptions): ToolDefinition {
138
139
  prompt,
139
140
  workingDirectory: cwd,
140
141
  runtimeContext: opts.runtimeContext,
142
+ // Hang the child off the executing tool's span, so the
143
+ // delegation appears inside the turn that asked for it rather
144
+ // than as a disconnected root trace. `create_task` has done
145
+ // this all along; this tool — the kernel's other delegation
146
+ // surface, and the one it exports as the canonical shape —
147
+ // did not.
148
+ ...(context.parentSpan ? { parentSpan: context.parentSpan } : {}),
141
149
  })
142
150
 
143
151
  onTaskLaunched?.(handle.taskId, {
@@ -153,26 +161,12 @@ export function buildAgentTool(opts: AgentToolOptions): ToolDefinition {
153
161
 
154
162
  const completed = await gateway.waitForTask(handle.taskId)
155
163
 
156
- // Two layers can disagree on whether the subagent succeeded:
157
- //
158
- // 1. `TaskHandle.state` the gateway's terminal task state.
159
- // Some gateways (e.g. vandal's) explicitly map
160
- // `result.status !== 'completed'` to `state = 'failed'`,
161
- // others (e.g. SDK's `LocalTaskGateway`) just forward
162
- // whatever the AgentManager set, which does not always
163
- // reflect run-level failure.
164
- // 2. `BaseAgentResult.status` — the run's own status. The
165
- // canonical source of truth for whether the agent actually
166
- // finished its work; `lastError` carries the failure
167
- // message when set.
168
- //
169
- // Treat the subagent as successful only when BOTH agree.
170
- // Reporting a failed subagent as successful would silently
171
- // hand the parent garbage output and make debugging
172
- // impossible, which is what review flagged on the first cut.
173
- const runStatus = completed.result?.status
174
- const succeeded =
175
- completed.state === 'completed' && (runStatus === undefined || runStatus === 'completed')
164
+ // Both authorities must agree see `taskSucceeded` for which two
165
+ // and why either alone is wrong. The reasoning used to live here
166
+ // alone, which is exactly how `create_task` came to ship without
167
+ // it: a review caught this site, and nothing carried the answer to
168
+ // the other one.
169
+ const succeeded = taskSucceeded(completed)
176
170
 
177
171
  const resultText =
178
172
  typeof completed.result?.result === 'string'
@@ -182,19 +176,17 @@ export function buildAgentTool(opts: AgentToolOptions): ToolDefinition {
182
176
  : ''
183
177
 
184
178
  if (!succeeded) {
185
- const failureLabel =
186
- completed.state !== 'completed' ? completed.state : (runStatus ?? 'failed')
187
179
  const detail =
188
180
  completed.result?.lastError ?? resultText ?? '(subagent provided no failure detail)'
189
181
  return {
190
182
  success: false,
191
183
  output: '',
192
- error: `Subagent ${agentId} ${failureLabel}: ${detail}`,
184
+ error: `Subagent ${agentId} ${failureLabel(completed)}: ${detail}`,
193
185
  data: {
194
186
  task_id: handle.taskId,
195
187
  subagent_type: agentId,
196
188
  state: completed.state,
197
- status: runStatus,
189
+ status: completed.result?.status,
198
190
  lastError: completed.result?.lastError,
199
191
  },
200
192
  }
@@ -221,7 +213,7 @@ export function buildAgentTool(opts: AgentToolOptions): ToolDefinition {
221
213
  subagent_type: agentId,
222
214
  result: resultText,
223
215
  state: completed.state,
224
- status: runStatus,
216
+ status: completed.result?.status,
225
217
  },
226
218
  }
227
219
  },
@@ -10,6 +10,7 @@ import type { TaskStore } from '../../types/task/index.js'
10
10
  import type { ToolDefinition } from '../../types/tool/index.js'
11
11
  import { defineTool } from '../defineTool.js'
12
12
  import { wrapUntrusted } from '../untrusted-envelope.js'
13
+ import { failureLabel, taskSucceeded } from './outcome.js'
13
14
  import { resolvePlanDependencies } from './plan-dependencies.js'
14
15
  import { describeWaitTimeout, waitForTaskWithBounds } from './wait-with-idle-bound.js'
15
16
 
@@ -457,6 +458,17 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
457
458
  // Naming the missing piece is the only response that tells them
458
459
  // what to change.
459
460
  if (background && !canLaunchInBackground) {
461
+ // The plan task was marked in progress a few lines above, on the
462
+ // assumption that a worker was about to run. Nothing is running,
463
+ // so leaving it there would show a plan step underway with no
464
+ // worker behind it — indefinitely, since nothing later will
465
+ // close a task whose launch never happened.
466
+ if (resolvedPlanTaskId && taskStore) {
467
+ await taskStore.update(resolvedPlanTaskId as `task_${string}`, {
468
+ status: 'failed',
469
+ description: 'Failed: the launch was refused before any worker started',
470
+ })
471
+ }
460
472
  return {
461
473
  success: false,
462
474
  output: '',
@@ -533,15 +545,26 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
533
545
  }
534
546
  }
535
547
  completionInbox?.claim(handle.taskId)
536
- const success = completed.state === 'completed'
548
+ // Both authorities, not just the gateway's. `finalizeChild` always
549
+ // calls `markCompleted`, so `state === 'completed'` holds for a
550
+ // child that ran and returned `status: 'failed'` — and this tool
551
+ // reported that child's error text to the model as its answer, with
552
+ // `isError: false`, while writing the plan task closed as though
553
+ // the work had been done.
554
+ const success = taskSucceeded(completed)
537
555
  const resultText =
538
556
  completed.result?.result ??
539
557
  completed.result?.lastError ??
540
- `Task finished with state: ${completed.state}`
558
+ `Task finished with state: ${failureLabel(completed)}`
541
559
 
542
560
  if (resolvedPlanTaskId && taskStore) {
561
+ // The status carries the outcome now, rather than `completed`
562
+ // with the failure written into prose. A reader scanning
563
+ // statuses saw work that had been done; only a reader of every
564
+ // description saw otherwise — and a dependent unit had no way
565
+ // to tell at all.
543
566
  await taskStore.update(resolvedPlanTaskId as `task_${string}`, {
544
- status: 'completed',
567
+ status: success ? 'completed' : 'failed',
545
568
  description: success ? undefined : `Failed: ${resultText.substring(0, 200)}`,
546
569
  })
547
570
  }
@@ -922,6 +945,13 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
922
945
  id: `step_${i + 1}`,
923
946
  description: step.description,
924
947
  toolName: step.agent_id ? 'create_task' : undefined,
948
+ // WHICH agent, not just whether there is one. The schema
949
+ // asks the model to name an agent per step and the answer
950
+ // was collapsed to the boolean above, so the human
951
+ // approving the plan saw that a step delegates and never
952
+ // to whom — at the one moment the difference can still be
953
+ // acted on.
954
+ ...(step.agent_id ? { agentId: step.agent_id } : {}),
925
955
  // Was `[]` unconditionally, which dropped every ordering
926
956
  // constraint the model was invited to express — and put an
927
957
  // empty dependency list in front of the human approving it.
@@ -0,0 +1,70 @@
1
+ import type { TaskHandle } from '../../types/agent/gateway.js'
2
+
3
+ /**
4
+ * Did this worker actually succeed?
5
+ *
6
+ * Two layers can disagree, and asking only one of them is how a failed worker
7
+ * gets reported as an answer:
8
+ *
9
+ * 1. **`TaskHandle.state`** — the gateway's terminal task state. Some gateways
10
+ * map a failed run to `state: 'failed'`; others forward whatever the agent
11
+ * manager set, which does not always reflect run-level failure. The kernel's
12
+ * own `finalizeChild` always calls `markCompleted`, so `state` is
13
+ * `'completed'` for a child that ran and returned `status: 'failed'`.
14
+ * 2. **`BaseAgentResult.status`** — the run's own status, and the canonical
15
+ * answer to whether the agent finished its work. `lastError` carries the
16
+ * message when it did not.
17
+ *
18
+ * So success requires BOTH to agree. Reporting a failed worker as successful
19
+ * hands the parent garbage output as though it were a result, and makes
20
+ * debugging impossible — the model reads an error as an answer and builds on
21
+ * it.
22
+ *
23
+ * **This lives here because it was written twice and omitted once**, and the
24
+ * omission was in `create_task`, the primary delegation surface. The version in
25
+ * the canonical `Agent` tool was correct because a review caught it there; the
26
+ * same review never reached the other site. A predicate that is easy to get
27
+ * wrong, and whose wrong answer is silent, belongs in one place that every
28
+ * caller reaches rather than in each caller's memory.
29
+ */
30
+ export function taskSucceeded(handle: Pick<TaskHandle, 'state' | 'result'>): boolean {
31
+ const runStatus = handle.result?.status
32
+ return handle.state === 'completed' && (runStatus === undefined || runStatus === 'completed')
33
+ }
34
+
35
+ /**
36
+ * Did this worker actually fail? **Not the negation of {@link taskSucceeded}.**
37
+ *
38
+ * Three answers exist, not two: succeeded, failed, and not settled yet. A task
39
+ * still running satisfies neither predicate, and that is the point — a caller
40
+ * deciding whether to tear down healthy siblings must act on a child that
41
+ * *failed*, never on one that merely has not succeeded yet. Writing this as
42
+ * `!taskSucceeded(handle)` would cancel a fan-out the moment the first child
43
+ * was still working.
44
+ *
45
+ * The two-authority rule applies here too, for the same reason: the kernel's
46
+ * `finalizeChild` always calls `markCompleted`, so a run that returned
47
+ * `status: 'failed'` carries `state: 'completed'`, and a check that read only
48
+ * the gateway state would never see it fail.
49
+ *
50
+ * Third copy of this knowledge, now in the one place `taskSucceeded` already
51
+ * lives — it was written independently in `LocalTaskGateway`, which got it
52
+ * right, but a rule that each caller has to remember is a rule one of them
53
+ * eventually forgets. That has already happened once with `taskSucceeded`.
54
+ */
55
+ export function taskFailed(handle: Pick<TaskHandle, 'state' | 'result'>): boolean {
56
+ return handle.state === 'failed' || handle.result?.status === 'failed'
57
+ }
58
+
59
+ /**
60
+ * What to call the failure, in the words of whichever layer reported it.
61
+ *
62
+ * The gateway state wins when it is the one that disagrees, because a task that
63
+ * never reached `completed` failed in a way the run status cannot describe — it
64
+ * was cancelled, or it timed out, and saying "failed" for those loses the
65
+ * distinction a reader needs to decide what to do next.
66
+ */
67
+ export function failureLabel(handle: Pick<TaskHandle, 'state' | 'result'>): string {
68
+ if (handle.state !== 'completed') return handle.state
69
+ return handle.result?.status ?? 'failed'
70
+ }
@@ -1,5 +1,5 @@
1
1
  import type { TaskId } from '../ids/index.js'
2
- import type { AgentRuntimeContext, BaseAgentResult } from './base.js'
2
+ import type { AgentRuntimeContext, BaseAgentConfig, BaseAgentResult } from './base.js'
3
3
  import type { AgentTaskState } from './task.js'
4
4
 
5
5
  export interface TaskHandle {
@@ -40,7 +40,24 @@ export interface CreateTaskOptions {
40
40
 
41
41
  runtimeContext?: AgentRuntimeContext
42
42
 
43
- configOverrides?: Record<string, unknown>
43
+ /**
44
+ * Config the spawned run should be built with, overriding what the
45
+ * agent's own definition supplies — the model it runs on, its iteration
46
+ * ceiling, its thinking or effort settings.
47
+ *
48
+ * **This was accepted and dropped.** `LocalTaskGateway.createTask` built
49
+ * its own `configOverrides` object out of `parentSpan` alone and never
50
+ * read this field, so a caller pinning a delegated run to a cheaper model
51
+ * got the agent's default model and no indication otherwise. It is
52
+ * forwarded now, with the dedicated {@link parentSpan} option winning if
53
+ * both name a span, since that one is the specific field for the job.
54
+ *
55
+ * Typed as `Partial<BaseAgentConfig>` rather than
56
+ * `Record<string, unknown>`: this lands on `SendMessageOptions`, which is
57
+ * already that shape, and the loose type let a misspelled key type-check
58
+ * and then do nothing — the same silence this field was already producing.
59
+ */
60
+ configOverrides?: Partial<BaseAgentConfig>
44
61
  }
45
62
 
46
63
  export interface TaskGateway {