@namzu/sdk 9.0.0 → 11.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 (52) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  3. package/dist/agents/SupervisorAgent.js +0 -5
  4. package/dist/agents/SupervisorAgent.js.map +1 -1
  5. package/dist/manager/plan/lifecycle.d.ts +10 -0
  6. package/dist/manager/plan/lifecycle.d.ts.map +1 -1
  7. package/dist/manager/plan/lifecycle.js +14 -0
  8. package/dist/manager/plan/lifecycle.js.map +1 -1
  9. package/dist/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.d.ts +2 -0
  10. package/dist/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.d.ts.map +1 -0
  11. package/dist/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.js +90 -0
  12. package/dist/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.js.map +1 -0
  13. package/dist/runtime/query/index.d.ts +0 -1
  14. package/dist/runtime/query/index.d.ts.map +1 -1
  15. package/dist/runtime/query/index.js +0 -1
  16. package/dist/runtime/query/index.js.map +1 -1
  17. package/dist/runtime/query/iteration/phases/context.d.ts +0 -17
  18. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  19. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  20. package/dist/runtime/query/result.d.ts.map +1 -1
  21. package/dist/runtime/query/result.js +17 -1
  22. package/dist/runtime/query/result.js.map +1 -1
  23. package/dist/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.d.ts +2 -0
  24. package/dist/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.d.ts.map +1 -0
  25. package/dist/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.js +139 -0
  26. package/dist/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.js.map +1 -0
  27. package/dist/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.d.ts +2 -0
  28. package/dist/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.d.ts.map +1 -0
  29. package/dist/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.js +160 -0
  30. package/dist/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.js.map +1 -0
  31. package/dist/tools/coordinator/__tests__/approve-plan.test.js +21 -6
  32. package/dist/tools/coordinator/__tests__/approve-plan.test.js.map +1 -1
  33. package/dist/tools/coordinator/__tests__/completion-delivery.test.js +4 -0
  34. package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -1
  35. package/dist/tools/coordinator/__tests__/task-list.test.js +44 -15
  36. package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
  37. package/dist/tools/coordinator/index.d.ts.map +1 -1
  38. package/dist/tools/coordinator/index.js +159 -8
  39. package/dist/tools/coordinator/index.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/agents/SupervisorAgent.ts +1 -8
  42. package/src/manager/plan/lifecycle.ts +14 -0
  43. package/src/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.ts +109 -0
  44. package/src/runtime/query/index.ts +0 -6
  45. package/src/runtime/query/iteration/phases/context.ts +0 -19
  46. package/src/runtime/query/result.ts +18 -1
  47. package/src/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.ts +171 -0
  48. package/src/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.ts +215 -0
  49. package/src/tools/coordinator/__tests__/approve-plan.test.ts +32 -11
  50. package/src/tools/coordinator/__tests__/completion-delivery.test.ts +7 -0
  51. package/src/tools/coordinator/__tests__/task-list.test.ts +47 -20
  52. package/src/tools/coordinator/index.ts +177 -8
@@ -88,14 +88,30 @@ describe('coordinator approve_plan tool', () => {
88
88
  ])
89
89
  })
90
90
 
91
- it('keeps the bare-approve tool_result output byte-identical', async () => {
91
+ it('opens with the historical approval sentence, then names the steps', async () => {
92
+ // This assertion used to be `toBe` on the whole string, guarding the
93
+ // approve-with-edits change against disturbing the bare-approve path.
94
+ // It was never a promise that the output would carry nothing more, and
95
+ // it now carries the step roster — without which `plan_step_id` and
96
+ // `update_plan_step` name ids the model has never been told.
92
97
  const result = await executeApprovePlan({ approved: true })
93
98
 
94
99
  expect(result.success).toBe(true)
95
- expect(result.output).toBe(
96
- 'Plan approved by user. Proceed with execution — launch workers via create_task.',
97
- )
98
- expect(result.data).toEqual({ approved: true, feedback: undefined })
100
+ expect(
101
+ result.output.startsWith(
102
+ 'Plan approved by user. Proceed with execution — launch workers via create_task.',
103
+ ),
104
+ ).toBe(true)
105
+ expect(result.output).toContain('step_1 — Extract uploaded DOCX files')
106
+ expect(result.data).toMatchObject({ approved: true, feedback: undefined })
107
+ })
108
+
109
+ it('carries the step ids in data, so a host does not have to parse prose', async () => {
110
+ const result = await executeApprovePlan({ approved: true })
111
+
112
+ expect((result.data as { steps: unknown }).steps).toEqual([
113
+ { step_id: 'step_1', description: 'Extract uploaded DOCX files', agent_id: undefined },
114
+ ])
99
115
  })
100
116
 
101
117
  it('embeds approve-with-edits feedback in the output and data', async () => {
@@ -105,12 +121,17 @@ describe('coordinator approve_plan tool', () => {
105
121
  })
106
122
 
107
123
  expect(result.success).toBe(true)
108
- expect(result.output).toBe(
109
- 'Plan approved by user with required edits — apply them during execution:\n' +
110
- 'Skip step 2 and use the staging database instead.\n' +
111
- 'Proceed with execution launch workers via create_task.',
112
- )
113
- expect(result.data).toEqual({
124
+ expect(
125
+ result.output.startsWith(
126
+ 'Plan approved by user with required edits apply them during execution:\n' +
127
+ 'Skip step 2 and use the staging database instead.\n' +
128
+ 'Proceed with execution — launch workers via create_task.',
129
+ ),
130
+ ).toBe(true)
131
+ // The edits stay ahead of the roster: what the user demanded is the
132
+ // first thing read, and the step list is reference material after it.
133
+ expect(result.output.indexOf('staging database')).toBeLessThan(result.output.indexOf('step_1'))
134
+ expect(result.data).toMatchObject({
114
135
  approved: true,
115
136
  feedback: 'Skip step 2 and use the staging database instead.',
116
137
  })
@@ -298,6 +298,13 @@ describe('waiting explicitly beats listing in a loop', () => {
298
298
  describe('the task listing carries the output it always had', () => {
299
299
  async function listWith(result: string): Promise<string> {
300
300
  const h = harness()
301
+ // Launch it first. The listing is scoped to what this run launched, so
302
+ // settling a task the tools never created describes a sibling run's
303
+ // work — which the listing now declines to show, correctly.
304
+ await toolNamed(h.tools, 'create_task').execute(
305
+ { agent_id: 'reviewer', prompt: 'go', description: 'review', background: true },
306
+ {} as never,
307
+ )
301
308
  h.settle({
302
309
  taskId: 'tsk_1' as TaskId,
303
310
  agentId: 'reviewer',
@@ -27,13 +27,27 @@ function makeContext(): ToolContext {
27
27
  }
28
28
  }
29
29
 
30
+ /**
31
+ * Hands back the seeded handles in order, one per `createTask`.
32
+ *
33
+ * The listing is scoped to what this tool set launched, so a fixture that
34
+ * only stuffed `listTasks()` would now list nothing — and a gateway holding
35
+ * tasks these tools never launched is precisely the sibling-run case the
36
+ * scope exists to refuse. So the tests launch through the front door and the
37
+ * fixture plays along.
38
+ */
30
39
  function gatewayWith(handles: TaskHandle[]): TaskGateway {
40
+ let nextLaunch = 0
31
41
  return {
32
42
  async createTask() {
33
- throw new Error('not used')
43
+ const h = handles[nextLaunch++]
44
+ if (!h) throw new Error('fixture ran out of seeded handles')
45
+ return h
34
46
  },
35
- async waitForTask() {
36
- throw new Error('not used')
47
+ async waitForTask(id) {
48
+ const h = handles.find((x) => x.taskId === id)
49
+ if (!h) throw new Error(`fixture has no handle ${id}`)
50
+ return h
37
51
  },
38
52
  async continueTask() {},
39
53
  cancelTask() {},
@@ -79,12 +93,29 @@ function handle(input: {
79
93
  }
80
94
  }
81
95
 
82
- function findAgentTaskList(gateway: TaskGateway) {
96
+ /**
97
+ * Build the coordinator surface, launch each seeded handle through
98
+ * `create_task`, and return `agent_task_list`.
99
+ *
100
+ * Launching is what puts the tasks in this run's scope. Reaching past it to
101
+ * seed the gateway directly would test a listing nobody can produce.
102
+ */
103
+ async function agentTaskListOver(seeded: TaskHandle[]) {
83
104
  const tools = buildCoordinatorTools({
84
- gateway,
105
+ gateway: gatewayWith(seeded),
85
106
  workingDirectory: '/tmp/test',
86
107
  allowedAgentIds: ['solution-architecture', 'enterprise-architecture'],
87
108
  })
109
+
110
+ const createTask = tools.find((tool) => tool.name === 'create_task')
111
+ if (!createTask) throw new Error('create_task tool missing from coordinator builder')
112
+ for (const h of seeded) {
113
+ await createTask.execute(
114
+ { agent_id: h.agentId, prompt: 'work', description: `launch ${h.taskId}` },
115
+ makeContext(),
116
+ )
117
+ }
118
+
88
119
  const t = tools.find((tool) => tool.name === 'agent_task_list')
89
120
  if (!t) throw new Error('agent_task_list tool missing from coordinator builder')
90
121
  return t
@@ -92,7 +123,7 @@ function findAgentTaskList(gateway: TaskGateway) {
92
123
 
93
124
  describe('coordinator agent_task_list tool', () => {
94
125
  it('lists every task with state, agent, and timing', async () => {
95
- const gateway = gatewayWith([
126
+ const seeded = [
96
127
  handle({
97
128
  id: 'task_a',
98
129
  agentId: 'solution-architecture',
@@ -114,9 +145,8 @@ describe('coordinator agent_task_list tool', () => {
114
145
  completedAt: 4000,
115
146
  lastError: 'bash exit 1',
116
147
  }),
117
- ])
118
-
119
- const tool = findAgentTaskList(gateway)
148
+ ]
149
+ const tool = await agentTaskListOver(seeded)
120
150
  const result = await tool.execute({}, makeContext())
121
151
  expect(result.success).toBe(true)
122
152
  expect(result.output).toMatch(/Tasks: 3 total/)
@@ -131,7 +161,7 @@ describe('coordinator agent_task_list tool', () => {
131
161
  })
132
162
 
133
163
  it('filters by state', async () => {
134
- const gateway = gatewayWith([
164
+ const seeded = [
135
165
  handle({
136
166
  id: 'task_a',
137
167
  agentId: 'solution-architecture',
@@ -145,9 +175,8 @@ describe('coordinator agent_task_list tool', () => {
145
175
  state: 'running',
146
176
  createdAt: 1000,
147
177
  }),
148
- ])
149
-
150
- const tool = findAgentTaskList(gateway)
178
+ ]
179
+ const tool = await agentTaskListOver(seeded)
151
180
  const result = await tool.execute({ state: 'running' }, makeContext())
152
181
  expect(result.success).toBe(true)
153
182
  const data = result.data as { items: Array<{ task_id: string }> }
@@ -157,7 +186,7 @@ describe('coordinator agent_task_list tool', () => {
157
186
  })
158
187
 
159
188
  it('handles an empty gateway', async () => {
160
- const tool = findAgentTaskList(gatewayWith([]))
189
+ const tool = await agentTaskListOver([])
161
190
  const result = await tool.execute({}, makeContext())
162
191
  expect(result.success).toBe(true)
163
192
  expect(result.output).toMatch(/Tasks: 0 total/)
@@ -224,7 +253,7 @@ describe('agent_task_list frames what a worker said', () => {
224
253
  }
225
254
 
226
255
  async function render(text: string): Promise<string> {
227
- const tool = findAgentTaskList(gatewayWith([withResult(text)]))
256
+ const tool = await agentTaskListOver([withResult(text)])
228
257
  const out = await tool.execute({}, makeContext())
229
258
  return out.output
230
259
  }
@@ -263,11 +292,9 @@ describe('agent_task_list frames what a worker said', () => {
263
292
  })
264
293
 
265
294
  it('says nothing extra for a task that produced no output', async () => {
266
- const tool = findAgentTaskList(
267
- gatewayWith([
268
- handle({ id: 'task_none', agentId: 'reviewer', state: 'running', createdAt: 0 }),
269
- ]),
270
- )
295
+ const tool = await agentTaskListOver([
296
+ handle({ id: 'task_none', agentId: 'reviewer', state: 'running', createdAt: 0 }),
297
+ ])
271
298
 
272
299
  const out = await tool.execute({}, makeContext())
273
300
 
@@ -308,6 +308,33 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
308
308
  // compatibility (Agent tool consumes it from its own path).
309
309
  } = opts
310
310
  const cwd = opts.workingDirectory
311
+
312
+ /**
313
+ * The tasks THIS surface launched — the scope of everything it will read back.
314
+ *
315
+ * A `TaskGateway` is shared on purpose: `SupervisorAgentConfig.gateway`
316
+ * exists so a host can hand the same one to several runs. `listTasks()` is
317
+ * therefore gateway-wide by design, and `agent_task_list` used to hand that
318
+ * straight to the model — so a supervisor could read a sibling run's worker
319
+ * output, including the `result` field, by listing. `wait_for_task` had the
320
+ * same reach through `getTask`.
321
+ *
322
+ * That is the leak `CompletionInbox` closed on the push side, through a
323
+ * different door: the inbox refuses a completion for a task it was not told
324
+ * about, precisely because `onTaskCompleted` is a broadcast. The pull side
325
+ * kept no such record and asked the gateway directly.
326
+ *
327
+ * The scope lives here rather than in `listTasks()` because the two answer
328
+ * different questions. A host calling `listTasks()` is the operator and may
329
+ * legitimately want everything on its gateway; a model calling
330
+ * `agent_task_list` is one run asking about its own work. Narrowing the
331
+ * gateway method would take the operator's view away to fix the model's.
332
+ *
333
+ * Consequence worth stating: a task launched through a DIFFERENT surface on
334
+ * the same gateway — `buildAgentTool`, or the host itself — is not listed
335
+ * here. That is the same rule, not an exception to it.
336
+ */
337
+ const launchedHere = new Set<TaskId>()
311
338
  void opts.onTaskLaunched
312
339
 
313
340
  const agentIdEnum = delegateSchema(agentIds)
@@ -386,6 +413,12 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
386
413
  .describe(
387
414
  'Existing planning task ID to link. If omitted, a planning task is auto-created.',
388
415
  ),
416
+ plan_step_id: z
417
+ .string()
418
+ .optional()
419
+ .describe(
420
+ 'The approve_plan step this launch carries out (e.g. "step_2"). Pass it and the step reports its own outcome — running on launch, completed or failed when the worker settles — so the plan can say how it went. Omit it only when this launch is not part of the approved plan.',
421
+ ),
389
422
  ...(canLaunchInBackground
390
423
  ? {
391
424
  background: z
@@ -407,9 +440,24 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
407
440
  // children were observed at 8m04s, which it would have survived by
408
441
  // under two minutes.
409
442
  timeoutMs: DELEGATION_TIMEOUT_MS,
410
- async execute({ agent_id, prompt, description, plan_task_id, background }, _context) {
443
+ async execute(
444
+ { agent_id, prompt, description, plan_task_id, plan_step_id, background },
445
+ _context,
446
+ ) {
411
447
  let resolvedPlanTaskId = plan_task_id
412
448
 
449
+ // The binding between a plan step and the work that carries it out.
450
+ // Without it a plan's steps had no relationship to any tool call, so
451
+ // nothing could ever observe how a step went — which is why a plan
452
+ // could report `failed` or stay `executing` forever but never
453
+ // `completed`.
454
+ const planStepId = plan_step_id
455
+ const reportStep = (status: 'running' | 'completed' | 'failed', error?: string): void => {
456
+ if (!planStepId) return
457
+ getPlanManager?.()?.updateStepStatus(planStepId, status, error)
458
+ }
459
+ reportStep('running')
460
+
413
461
  if (taskStore) {
414
462
  if (resolvedPlanTaskId) {
415
463
  await taskStore.update(resolvedPlanTaskId as `task_${string}`, {
@@ -446,6 +494,9 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
446
494
  // is exactly the blocking launch whose wait was abandoned.
447
495
  completionInbox?.launched(handle.taskId)
448
496
 
497
+ // ...and whose it is for the READ side too. See `launchedHere`.
498
+ launchedHere.add(handle.taskId)
499
+
449
500
  // A background launch asked for with nowhere to deliver it is
450
501
  // REFUSED, not quietly turned into a blocking one.
451
502
  //
@@ -469,6 +520,10 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
469
520
  description: 'Failed: the launch was refused before any worker started',
470
521
  })
471
522
  }
523
+ // Same reason the plan task is closed here: nothing is running,
524
+ // so a step left `running` would show work underway with no
525
+ // worker behind it, and the plan could never settle.
526
+ reportStep('failed', 'the launch was refused before any worker started')
472
527
  return {
473
528
  success: false,
474
529
  output: '',
@@ -569,6 +624,11 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
569
624
  })
570
625
  }
571
626
 
627
+ // The step reports the same outcome, from the same two authorities.
628
+ // This is the only point in a delegated launch where the answer is
629
+ // actually known.
630
+ reportStep(success ? 'completed' : 'failed', success ? undefined : resultText.slice(0, 200))
631
+
572
632
  return {
573
633
  success,
574
634
  // Framed, because a delegated worker is the component MOST
@@ -631,6 +691,21 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
631
691
  // waiting. Same bound as the launch it is waiting on.
632
692
  timeoutMs: DELEGATION_TIMEOUT_MS,
633
693
  async execute({ task_id }, _context) {
694
+ // Same scope as the listing — see `launchedHere`. Asked FIRST, so a
695
+ // task belonging to a sibling run on a shared gateway is refused
696
+ // here rather than waited on and then read.
697
+ if (!launchedHere.has(task_id as TaskId)) {
698
+ // Deliberately does not distinguish "never existed" from
699
+ // "belongs to someone else". The second answer is itself the
700
+ // leak in miniature: it confirms a task id a run was not
701
+ // supposed to know about.
702
+ return {
703
+ success: false,
704
+ output: `No task ${task_id} was launched by this run. Call agent_task_list to see the tasks you can wait on.`,
705
+ data: { task_id },
706
+ }
707
+ }
708
+
634
709
  const known = gateway.getTask(task_id as TaskId)
635
710
  if (!known) {
636
711
  return {
@@ -716,7 +791,7 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
716
791
 
717
792
  const agentTaskList = defineTool({
718
793
  name: 'agent_task_list',
719
- description: `Inspect the live state of every agent task launched on this gateway via create_task: returns each task's id, agent, state (pending/running/completed/failed/canceled), and timing. Distinct from the plan-task store's \`task_list\` (which lists planning tasks): this tool lists running/completed worker invocations. ${listingAdvice}`,
794
+ description: `Inspect the live state of the agent tasks YOU launched with create_task: returns each task's id, agent, state (pending/running/completed/failed/canceled), and timing. Tasks launched by another run are not listed, even when it shares this gateway. Distinct from the plan-task store's \`task_list\` (which lists planning tasks): this tool lists running/completed worker invocations. ${listingAdvice}`,
720
795
  inputSchema: z.object({
721
796
  state: z
722
797
  .enum(['pending', 'running', 'completed', 'failed', 'canceled'])
@@ -729,7 +804,10 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
729
804
  destructive: false,
730
805
  concurrencySafe: true,
731
806
  async execute({ state }) {
732
- const handles = gateway.listTasks()
807
+ // Scoped to this run's own launches — see `launchedHere`. The
808
+ // gateway may hold a sibling run's tasks, and this listing is not
809
+ // the door to them.
810
+ const handles = gateway.listTasks().filter((h) => launchedHere.has(h.taskId))
733
811
  const filtered = state ? handles.filter((h) => h.state === state) : handles
734
812
  const items = filtered.map((h) => {
735
813
  const runStatus = h.result?.status
@@ -965,17 +1043,38 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
965
1043
 
966
1044
  if (response.approved) {
967
1045
  pm.startExecution()
1046
+
1047
+ // The step ids, because nothing else tells the model what
1048
+ // they are. `plan_step_id` on create_task and `step_id` on
1049
+ // update_plan_step are both unusable without them — a
1050
+ // binding the caller cannot name is a binding that does not
1051
+ // exist. Rendered from the plan as approved, so a plan the
1052
+ // user edited lists the steps they actually approved.
1053
+ const roster = (pm.active?.steps ?? [])
1054
+ .map((s) => ` ${s.id} — ${s.description}${s.agentId ? ` (${s.agentId})` : ''}`)
1055
+ .join('\n')
1056
+ const howToReport = roster
1057
+ ? `\nSteps, and how each reports its outcome:\n${roster}\nPass plan_step_id to create_task for a delegated step; call update_plan_step for one you do yourself. The plan cannot report success until every step has reported.`
1058
+ : ''
1059
+
968
1060
  // Approve-with-edits: when the user attached feedback to an
969
1061
  // approval, embed it in the model-visible output so the
970
- // supervisor applies the edits during execution. A bare
971
- // approve keeps the historical output byte-identical.
1062
+ // supervisor applies the edits during execution.
972
1063
  const output = response.feedback
973
- ? `Plan approved by user with required edits — apply them during execution:\n${response.feedback}\nProceed with execution — launch workers via create_task.`
974
- : 'Plan approved by user. Proceed with execution — launch workers via create_task.'
1064
+ ? `Plan approved by user with required edits — apply them during execution:\n${response.feedback}\nProceed with execution — launch workers via create_task.${howToReport}`
1065
+ : `Plan approved by user. Proceed with execution — launch workers via create_task.${howToReport}`
975
1066
  return {
976
1067
  success: true,
977
1068
  output,
978
- data: { approved: true, feedback: response.feedback },
1069
+ data: {
1070
+ approved: true,
1071
+ feedback: response.feedback,
1072
+ steps: (pm.active?.steps ?? []).map((s) => ({
1073
+ step_id: s.id,
1074
+ description: s.description,
1075
+ agent_id: s.agentId,
1076
+ })),
1077
+ },
979
1078
  }
980
1079
  }
981
1080
 
@@ -991,6 +1090,76 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
991
1090
  },
992
1091
  })
993
1092
  tools.push(approvePlan)
1093
+
1094
+ /**
1095
+ * How a step the ORCHESTRATOR did reports its own outcome.
1096
+ *
1097
+ * `create_task` reports the steps it carries out, which covers every
1098
+ * delegated step. A step with no `agent_id` is the orchestrator's own
1099
+ * work and has no tool call to bind to — so without this there is no
1100
+ * way for it to ever report, and a plan containing one could never
1101
+ * settle no matter how well it went.
1102
+ *
1103
+ * `skipped` is a first-class outcome and not a euphemism for failure:
1104
+ * a plan that turned out not to need a step went right, and forcing
1105
+ * that into `completed` or `failed` would make the plan lie in one
1106
+ * direction or the other.
1107
+ */
1108
+ const updatePlanStep = defineTool({
1109
+ name: 'update_plan_step',
1110
+ description:
1111
+ 'Report how a step of the approved plan went. Use it for steps YOU carried out — steps delegated with create_task report themselves when you pass plan_step_id. Call it as each step settles, not in a batch at the end: the plan cannot say it succeeded until every step has reported, and an unreported step is not scored as a failure, it simply leaves the plan unsettled. Use "skipped" for a step that turned out not to be needed; that is a successful outcome, not a failure.',
1112
+ inputSchema: z.object({
1113
+ step_id: z.string().describe('The plan step id, e.g. "step_2".'),
1114
+ status: z
1115
+ .enum(['completed', 'skipped', 'failed'])
1116
+ .describe(
1117
+ 'How it went. "skipped" means the step was not needed and the plan is still on track.',
1118
+ ),
1119
+ error: z
1120
+ .string()
1121
+ .optional()
1122
+ .describe('What went wrong. Only meaningful with status "failed".'),
1123
+ }),
1124
+ category: 'custom',
1125
+ permissions: [],
1126
+ readOnly: false,
1127
+ destructive: false,
1128
+ concurrencySafe: true,
1129
+ async execute({ step_id, status, error }) {
1130
+ const pm = getPlanManager?.()
1131
+ if (!pm?.active) {
1132
+ return {
1133
+ success: false,
1134
+ output: '',
1135
+ error: 'There is no active plan to report against. Call approve_plan first.',
1136
+ }
1137
+ }
1138
+
1139
+ const step = pm.updateStepStatus(step_id, status, error)
1140
+ if (!step) {
1141
+ const known = pm.active.steps.map((s) => s.id).join(', ')
1142
+ return {
1143
+ success: false,
1144
+ output: '',
1145
+ error: `No plan step "${step_id}". This plan's steps are: ${known || '(none)'}.`,
1146
+ }
1147
+ }
1148
+
1149
+ const outstanding = pm.unreportedSteps
1150
+ return {
1151
+ success: true,
1152
+ output:
1153
+ outstanding.length === 0
1154
+ ? `Step ${step_id} reported as ${status}. Every step has now reported.`
1155
+ : `Step ${step_id} reported as ${status}. Still unreported: ${outstanding
1156
+ .map((s) => s.id)
1157
+ .join(', ')}.`,
1158
+ data: { step_id, status, unreported: outstanding.map((s) => s.id) },
1159
+ }
1160
+ },
1161
+ })
1162
+ tools.push(updatePlanStep)
994
1163
  }
995
1164
 
996
1165
  if (resumeHandler && runId) {