@genesislcap/ai-assistant 14.452.0 → 14.452.1

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.
@@ -0,0 +1,176 @@
1
+ # Migration Guide — GENC-1312 (Sub-agents must return via a tool call)
2
+
3
+ Sub-agents now **always finish by calling a tool** and hand a **typed result** back
4
+ to the caller. Previously a sub-agent could also end its turn with a plain-text
5
+ answer, and `requestSubAgent` would return that text as a `string` fallback. That
6
+ fallback is gone: sub-agents run with tool use forced, and `requestSubAgent`
7
+ resolves to a discriminated union — either the structured result, or a typed
8
+ failure reason.
9
+
10
+ This affects you **only if your tool handlers call `requestSubAgent`**. Agents that
11
+ declare no sub-agents, top-level agent behaviour, `completeSubAgent`, and
12
+ `SubAgentRequestOptions` are all unchanged.
13
+
14
+ > The sub-agent API is `@beta`. This is a deliberate, documented breaking change
15
+ > on that surface.
16
+
17
+ ---
18
+
19
+ ## 1. `requestSubAgent` returns a discriminated union
20
+
21
+ The return type changed from `Promise<T | string>` to:
22
+
23
+ ```ts
24
+ Promise<{ ok: true; result: T } | { ok: false; reason: SubAgentFailureReason }>
25
+ ```
26
+
27
+ ```ts
28
+ type SubAgentFailureReason =
29
+ | 'max_iterations' // loop ended without the completion tool being called
30
+ | 'malformed_tool_call' // provider returned unparseable tool calls after retries
31
+ | 'empty_response' // model returned an empty response after retries
32
+ | 'unknown_tool_limit'; // model repeatedly called tools it doesn't have
33
+ ```
34
+
35
+ `SubAgentFailureReason` is exported from `@genesislcap/foundation-ai`.
36
+
37
+ ### Before
38
+
39
+ ```ts
40
+ const handlers: ChatToolHandlers<typeof extractorAgent> = {
41
+ process_file: async (args, context) => {
42
+ if (!context.requestSubAgent) {
43
+ return { error: 'Sub-agent support is not available in this context.' };
44
+ }
45
+ const { file_name } = args as { file_name: string };
46
+
47
+ const result = await context.requestSubAgent<ExtractedData>('extractor', {
48
+ task: `Extract all rows from "${file_name}".`,
49
+ });
50
+
51
+ // result was `ExtractedData | string`
52
+ if (typeof result === 'string') {
53
+ // sub-agent finished with plain text — handle "gracefully"
54
+ return { error: result };
55
+ }
56
+ return result;
57
+ },
58
+ };
59
+ ```
60
+
61
+ ### After
62
+
63
+ ```ts
64
+ const handlers: ChatToolHandlers<typeof extractorAgent> = {
65
+ process_file: async (args, context) => {
66
+ if (!context.requestSubAgent) {
67
+ return { error: 'Sub-agent support is not available in this context.' };
68
+ }
69
+ const { file_name } = args as { file_name: string };
70
+
71
+ const outcome = await context.requestSubAgent<ExtractedData>('extractor', {
72
+ task: `Extract all rows from "${file_name}".`,
73
+ });
74
+
75
+ if (!outcome.ok) {
76
+ // The sub-agent didn't complete. Decide how to recover — typically
77
+ // early-return the issue back to *this* agent so it can retry, ask the
78
+ // user for help, or call a planner tool again.
79
+ return {
80
+ error: `Couldn't extract from "${file_name}" (${outcome.reason}). Ask the user to retry or try a different file.`,
81
+ };
82
+ }
83
+
84
+ return outcome.result; // fully typed as ExtractedData
85
+ },
86
+ };
87
+ ```
88
+
89
+ ### Mechanical migration
90
+
91
+ - `if (typeof result === 'string')` → `if (!outcome.ok)`.
92
+ - The success value moves from `result` to `outcome.result`.
93
+ - There is no longer an untyped string success path. If you called
94
+ `requestSubAgent` **without** a type parameter, the success payload is now
95
+ `never` — pass `<T>` to describe the result your completion tool returns.
96
+
97
+ ---
98
+
99
+ ## 2. Sub-agents must finish by calling a tool
100
+
101
+ Sub-agents now run with tool use forced every turn (Anthropic
102
+ `tool_choice: { type: 'any' }`, Gemini `functionCallingConfig.mode: 'ANY'`). A
103
+ sub-agent can no longer end a turn with a free-text answer — the only clean way
104
+ for it to finish is to call a tool whose handler invokes `completeSubAgent`.
105
+
106
+ **What you must check:** every sub-agent declares a completion tool (a normal tool
107
+ whose handler calls `context.completeSubAgent(result)`), and its prompt directs
108
+ the model to call it when done. A sub-agent with no completion tool can never
109
+ finish and will fail with `reason: 'max_iterations'` on every run.
110
+
111
+ This is the same `completeSubAgent` mechanism as before — its signature and the
112
+ per-agent schema you attach to your completion tool are unchanged. You keep full
113
+ control of the returned shape; only the *fallback* behaviour was removed.
114
+
115
+ > If a sub-agent's natural output is prose (e.g. a drafted paragraph), return it
116
+ > through the completion tool's payload — `completeSubAgent({ text })` — rather
117
+ > than relying on a free-text turn. Conversational, user-facing flows belong to
118
+ > top-level / stateful agents, not sub-agents.
119
+
120
+ ---
121
+
122
+ ## 3. Handling failures
123
+
124
+ A sub-agent failure is **not** surfaced to the user from inside the sub-agent
125
+ anymore — no apology message is appended. Instead the failure is returned to your
126
+ tool handler as `{ ok: false, reason }`, and **you decide** what happens next. The
127
+ recommended pattern is to early-return the issue information to the parent agent
128
+ so it can choose a recovery path:
129
+
130
+ ```ts
131
+ const outcome = await context.requestSubAgent<PlannedData>('planner', { task });
132
+ if (!outcome.ok) {
133
+ return {
134
+ error: `Planning didn't complete (${outcome.reason}). Ask the user for the missing details, or try again.`,
135
+ };
136
+ }
137
+ ```
138
+
139
+ The parent agent then sees that tool result and reacts (retry, re-plan, ask the
140
+ user) like any other tool outcome. Each failure is also recorded in the debug log
141
+ as a `subagent.failed` meta event (with the agent name and reason) plus the
142
+ existing `turn.error` entry, now tagged `isSubAgent: true`.
143
+
144
+ ---
145
+
146
+ ## 4. New `ChatRequestOptions.toolChoice` (additive — no action needed)
147
+
148
+ `ChatRequestOptions` gained an optional `toolChoice?: 'auto' | 'required'`. The
149
+ built-in Anthropic and Gemini transports translate `'required'` into the
150
+ provider's force-a-tool-call setting. This is additive and defaults to `'auto'`
151
+ (may-call), so existing code is unaffected.
152
+
153
+ If you maintain a **custom `ChatTransport`**, you can ignore `toolChoice` (the
154
+ field is optional). To support forced tool use in sub-agent loops, map
155
+ `'required'` to your provider's equivalent and only apply it when tools are
156
+ present.
157
+
158
+ > `'required'` is incompatible with Anthropic extended/adaptive thinking — a
159
+ > request must not enable both. The built-in chat transport never sets `thinking`,
160
+ > so they don't collide.
161
+
162
+ ---
163
+
164
+ ## Quick reference checklist
165
+
166
+ - [ ] For every `requestSubAgent` call: replace `typeof result === 'string'` with
167
+ `!outcome.ok`, and read the success value from `outcome.result`.
168
+ - [ ] Pass a type parameter (`requestSubAgent<T>(...)`) so the success payload is
169
+ typed.
170
+ - [ ] On `!outcome.ok`, return the issue back to the parent agent (or otherwise
171
+ recover) — don't assume a string result.
172
+ - [ ] Confirm every sub-agent has a completion tool that calls `completeSubAgent`,
173
+ and that its prompt tells the model to call it when finished.
174
+ - [ ] If a sub-agent produced user-facing prose via a final text turn, move that
175
+ text into the completion tool's payload.
176
+ - [ ] Custom `ChatTransport` only: optionally honour `toolChoice: 'required'`.
package/docs/sub_agent.md CHANGED
@@ -62,13 +62,17 @@ Tool handlers receive `requestSubAgent` on their context object alongside `reque
62
62
  const processTradeFile = async (args, context) => {
63
63
  const { file_name } = args as { file_name: string };
64
64
 
65
- const result = await context.requestSubAgent('trade_file_extractor', {
65
+ const outcome = await context.requestSubAgent('trade_file_extractor', {
66
66
  task: `Extract all trade rows from the attached file named "${file_name}".`,
67
67
  });
68
68
 
69
- // result is either the structured value from completeSubAgent, or the
70
- // sub-agent's final assistant message text as a string fallback.
71
- return result;
69
+ // `outcome` is a discriminated union — either the structured value from
70
+ // completeSubAgent, or a typed failure reason. Decide how to recover; here we
71
+ // hand the issue back to this agent.
72
+ if (!outcome.ok) {
73
+ return { error: `Extraction didn't complete (${outcome.reason}).` };
74
+ }
75
+ return outcome.result;
72
76
  };
73
77
  ```
74
78
 
@@ -108,7 +112,9 @@ interface SubAgentRequestOptions {
108
112
 
109
113
  ## Returning structured results: `completeSubAgent`
110
114
 
111
- By default, `requestSubAgent` returns the sub-agent's final assistant message text as a `string`. For structured data, define a completion tool on the sub-agent and call `completeSubAgent` from its handler:
115
+ Sub-agents run with **tool use forced**, so a sub-agent can only end a turn by
116
+ calling a tool — it cannot return a free-text answer. To finish, define a
117
+ completion tool on the sub-agent and call `completeSubAgent` from its handler:
112
118
 
113
119
  ```ts
114
120
  const toolHandlers: ChatToolHandlers = {
@@ -120,26 +126,40 @@ const toolHandlers: ChatToolHandlers = {
120
126
  };
121
127
  ```
122
128
 
123
- When `completeSubAgent` is called, the sub-agent's tool loop exits and `requestSubAgent` resolves with the value passed to `completeSubAgent`. If `completeSubAgent` is never called, the sub-agent runs to natural completion and returns its final text.
129
+ When `completeSubAgent` is called, the sub-agent's tool loop exits and `requestSubAgent` resolves with `{ ok: true, result }`. If the loop ends without `completeSubAgent` ever being called (it hit the iteration cap, or the provider repeatedly returned malformed/empty/unknown tool calls), `requestSubAgent` resolves with `{ ok: false, reason }` instead — there is no plain-text fallback.
124
130
 
125
- The return type of `requestSubAgent<T>` is `T | string`:
126
- - `T` — when the sub-agent called `completeSubAgent(result)`. Fully typed, no `JSON.parse`.
127
- - `string` — when the sub-agent finished naturally without calling `completeSubAgent`.
131
+ > Every sub-agent must declare a completion tool and be prompted to call it when done. A sub-agent with no completion tool can never finish and will always resolve with `reason: 'max_iterations'`.
128
132
 
129
- With `T` defaulting to `never`, `T | string` collapses to `string` when no type parameter is provided — untyped callers get a plain string with no extra ceremony.
133
+ The return type of `requestSubAgent<T>` is:
130
134
 
131
135
  ```ts
132
- const result = await context.requestSubAgent<ExtractedTrades>('trade_file_extractor', {
136
+ Promise<{ ok: true; result: T } | { ok: false; reason: SubAgentFailureReason }>;
137
+
138
+ type SubAgentFailureReason =
139
+ | 'max_iterations'
140
+ | 'malformed_tool_call'
141
+ | 'empty_response'
142
+ | 'unknown_tool_limit';
143
+ ```
144
+
145
+ Branch on `ok` in the calling handler and decide how to recover — typically by handing the issue back to the parent agent:
146
+
147
+ ```ts
148
+ const outcome = await context.requestSubAgent<ExtractedTrades>('trade_file_extractor', {
133
149
  task: `Extract all trades from "${file_name}".`,
134
150
  });
135
151
 
136
- if (typeof result === 'string') {
137
- // handle gracefullysub-agent returned plain text
138
- } else {
139
- const { rows } = result; // fully typed
152
+ if (!outcome.ok) {
153
+ // sub-agent didn't complete let this agent retry or ask the user
154
+ return { error: `Extraction didn't complete (${outcome.reason}).` };
140
155
  }
156
+ const { rows } = outcome.result; // fully typed
141
157
  ```
142
158
 
159
+ Pass a type parameter so `outcome.result` is typed; without one it defaults to `never`.
160
+
161
+ > Migrating from the old `T | string` return? See [`migration-GENC-1312.md`](./migration-GENC-1312.md).
162
+
143
163
  ---
144
164
 
145
165
  ## TypeScript types
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@genesislcap/ai-assistant",
3
3
  "description": "Genesis AI Assistant micro-frontend",
4
- "version": "14.452.0",
4
+ "version": "14.452.1",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -64,24 +64,24 @@
64
64
  }
65
65
  },
66
66
  "devDependencies": {
67
- "@genesislcap/foundation-testing": "14.452.0",
68
- "@genesislcap/genx": "14.452.0",
69
- "@genesislcap/rollup-builder": "14.452.0",
70
- "@genesislcap/ts-builder": "14.452.0",
71
- "@genesislcap/uvu-playwright-builder": "14.452.0",
72
- "@genesislcap/vite-builder": "14.452.0",
73
- "@genesislcap/webpack-builder": "14.452.0",
67
+ "@genesislcap/foundation-testing": "14.452.1",
68
+ "@genesislcap/genx": "14.452.1",
69
+ "@genesislcap/rollup-builder": "14.452.1",
70
+ "@genesislcap/ts-builder": "14.452.1",
71
+ "@genesislcap/uvu-playwright-builder": "14.452.1",
72
+ "@genesislcap/vite-builder": "14.452.1",
73
+ "@genesislcap/webpack-builder": "14.452.1",
74
74
  "@types/dompurify": "^3.0.5",
75
75
  "@types/marked": "^5.0.2"
76
76
  },
77
77
  "dependencies": {
78
- "@genesislcap/foundation-ai": "14.452.0",
79
- "@genesislcap/foundation-logger": "14.452.0",
80
- "@genesislcap/foundation-redux": "14.452.0",
81
- "@genesislcap/foundation-ui": "14.452.0",
82
- "@genesislcap/foundation-utils": "14.452.0",
83
- "@genesislcap/rapid-design-system": "14.452.0",
84
- "@genesislcap/web-core": "14.452.0",
78
+ "@genesislcap/foundation-ai": "14.452.1",
79
+ "@genesislcap/foundation-logger": "14.452.1",
80
+ "@genesislcap/foundation-redux": "14.452.1",
81
+ "@genesislcap/foundation-ui": "14.452.1",
82
+ "@genesislcap/foundation-utils": "14.452.1",
83
+ "@genesislcap/rapid-design-system": "14.452.1",
84
+ "@genesislcap/web-core": "14.452.1",
85
85
  "dompurify": "^3.3.1",
86
86
  "marked": "^17.0.3"
87
87
  },
@@ -93,5 +93,5 @@
93
93
  "publishConfig": {
94
94
  "access": "public"
95
95
  },
96
- "gitHead": "f2d34cef52ecea070247f239e9274d9d7bb74784"
96
+ "gitHead": "57cd7afd42c9a1554432603c66e4e5f750c3dc08"
97
97
  }
@@ -32,19 +32,24 @@ import { ChatDriver } from './chat-driver';
32
32
  interface ScriptedProvider extends AIProvider {
33
33
  /** Tool names advertised to the model on each `chat()` call, in order. */
34
34
  advertisedPerCall: string[][];
35
+ /** `toolChoice` seen on each `chat()` call, in order (sub-agents force it). */
36
+ toolChoicePerCall: Array<'auto' | 'required' | undefined>;
35
37
  }
36
38
 
37
39
  const scriptedProvider = (responses: ChatMessage[]): ScriptedProvider => {
38
40
  const queue = [...responses];
39
41
  const advertisedPerCall: string[][] = [];
42
+ const toolChoicePerCall: Array<'auto' | 'required' | undefined> = [];
40
43
  return {
41
44
  advertisedPerCall,
45
+ toolChoicePerCall,
42
46
  chat: async (
43
47
  _history: ChatMessage[],
44
48
  _userMessage: string,
45
49
  options?: ChatRequestOptions,
46
50
  ): Promise<ChatMessage> => {
47
51
  advertisedPerCall.push((options?.tools ?? []).map((t) => t.name));
52
+ toolChoicePerCall.push(options?.toolChoice);
48
53
  // Once the script is exhausted, end the turn with a plain text reply.
49
54
  return queue.shift() ?? { role: 'assistant', content: 'done' };
50
55
  },
@@ -281,11 +286,11 @@ stale('splits stale vs hallucinated tools on the unknown-tool-limit error', asyn
281
286
  : { tool_b: async () => 'b done' },
282
287
  });
283
288
 
284
- // One real call to advance to B, then 5 consecutive stale calls — the 5th
285
- // trips DEFAULT_MAX_UNKNOWN_TOOL_CALLS and ends the turn.
289
+ // One real call to advance to B, then 10 consecutive stale calls — the 10th
290
+ // trips the stale ceiling (MAX_STALE_TOOL_CALLS, 2x the hallucination limit) and ends the turn.
286
291
  const provider = scriptedProvider([
287
292
  callsTool('tool_a', 'real'),
288
- ...Array.from({ length: 5 }, (_unused, i) => callsTool('tool_a', `stale-${i}`)),
293
+ ...Array.from({ length: 10 }, (_unused, i) => callsTool('tool_a', `stale-${i}`)),
289
294
  ]);
290
295
  const driver = makeDriver(config, provider, sessionKey);
291
296
 
@@ -303,7 +308,7 @@ stale('splits stale vs hallucinated tools on the unknown-tool-limit error', asyn
303
308
  // Every stale attempt — not just the final limit error — is in the download log.
304
309
  assert.is(
305
310
  unresolvedEvents(sessionKey).filter((d) => d.kind === 'stale').length,
306
- 5,
311
+ 10,
307
312
  'each stale attempt should be recorded as its own tool.unresolved event',
308
313
  );
309
314
 
@@ -313,3 +318,181 @@ stale('splits stale vs hallucinated tools on the unknown-tool-limit error', asyn
313
318
  });
314
319
 
315
320
  stale.run();
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // sub-agents — forced tool use + typed completion/failure union (GENC-1312)
324
+ //
325
+ // A child sub-agent driver shares the parent's provider registry, so one
326
+ // scripted queue drives both: script the parent's delegating turn, then the
327
+ // worker's turn(s), in order.
328
+ // ---------------------------------------------------------------------------
329
+
330
+ const subagent = createLogicSuite('ChatDriver sub-agents');
331
+
332
+ subagent.after(() => {
333
+ // Safe to call again even if `stale` already closed it — close() is
334
+ // idempotent and cross-tab publishes are guarded by `&& this.channel`.
335
+ agenticActivityBus.close();
336
+ });
337
+
338
+ /** A sub-agent named `worker` that finishes by calling `completeSubAgent`. */
339
+ const completingWorker = (result: unknown): AgentConfig =>
340
+ agent({
341
+ name: 'worker',
342
+ toolDefinitions: [def('finish')],
343
+ toolHandlers: {
344
+ finish: async (_args, ctx) => {
345
+ ctx.completeSubAgent?.(result);
346
+ return 'finished';
347
+ },
348
+ },
349
+ });
350
+
351
+ /** A parent that delegates to `worker` and reports the outcome via `capture`. */
352
+ const delegatingParent = (sub: AgentConfig, capture: (outcome: unknown) => void): AgentConfig =>
353
+ agent({
354
+ name: 'boss',
355
+ subAgents: [sub],
356
+ toolDefinitions: [def('delegate')],
357
+ toolHandlers: {
358
+ delegate: async (_args, ctx) => {
359
+ const outcome = await ctx.requestSubAgent!('worker', { task: 'do it' });
360
+ capture(outcome);
361
+ return outcome.ok ? 'sub-agent completed' : `sub-agent failed: ${outcome.reason}`;
362
+ },
363
+ },
364
+ });
365
+
366
+ subagent('resolves { ok: true, result } when the sub-agent calls completeSubAgent', async () => {
367
+ let outcome: unknown;
368
+ const parent = delegatingParent(completingWorker({ value: 42 }), (o) => {
369
+ outcome = o;
370
+ });
371
+ const provider = scriptedProvider([
372
+ callsTool('delegate', 'd1'), // parent delegates to the worker
373
+ callsTool('finish', 'f1'), // worker completes
374
+ ]);
375
+
376
+ await makeDriver(parent, provider).sendMessage('go');
377
+
378
+ assert.equal(outcome, { ok: true, result: { value: 42 } });
379
+ });
380
+
381
+ subagent('forces tool use on the sub-agent turn but not the parent turn', async () => {
382
+ const parent = delegatingParent(completingWorker({ done: true }), () => {});
383
+ const provider = scriptedProvider([callsTool('delegate', 'd1'), callsTool('finish', 'f1')]);
384
+
385
+ await makeDriver(parent, provider).sendMessage('go');
386
+
387
+ // Call 0 is the parent's turn (may-call); call 1 is the worker's turn (must-call).
388
+ assert.is(provider.toolChoicePerCall[0], undefined, 'parent turn is not forced');
389
+ assert.is(provider.toolChoicePerCall[1], 'required', 'sub-agent turn forces a tool call');
390
+ assert.ok(
391
+ provider.advertisedPerCall[1].includes('finish'),
392
+ 'the worker advertised its completion tool',
393
+ );
394
+ });
395
+
396
+ subagent(
397
+ 'resolves { ok: false, reason } and records telemetry when the sub-agent never completes',
398
+ async () => {
399
+ const sessionKey = 'subagent-unknown-tool-test';
400
+ clearMetaEventRegistry();
401
+
402
+ let outcome: unknown;
403
+ const worker = agent({
404
+ name: 'worker',
405
+ toolDefinitions: [def('real')],
406
+ toolHandlers: { real: async () => 'ok' },
407
+ });
408
+ const parent = delegatingParent(worker, (o) => {
409
+ outcome = o;
410
+ });
411
+
412
+ // The worker repeatedly calls a tool it was never given, tripping the
413
+ // unknown-tool limit (DEFAULT_MAX_UNKNOWN_TOOL_CALLS = 5) without completing.
414
+ const provider = scriptedProvider([
415
+ callsTool('delegate', 'd1'),
416
+ ...Array.from({ length: 5 }, (_unused, i) => callsTool('made_up', `u${i}`)),
417
+ ]);
418
+
419
+ await makeDriver(parent, provider, sessionKey).sendMessage('go');
420
+
421
+ assert.equal(outcome, { ok: false, reason: 'unknown_tool_limit' });
422
+ // The failure surfaces as a high-importance `subagent.failed` meta event,
423
+ // recorded under the PARENT driver's session so it lands on the user-visible
424
+ // debug-log timeline — not orphaned in the child's own session bucket.
425
+ assert.ok(
426
+ getMetaEvents(sessionKey).some(
427
+ (e) =>
428
+ e.type === 'subagent.failed' &&
429
+ e.detail?.agent === 'worker' &&
430
+ e.detail?.reason === 'unknown_tool_limit',
431
+ ),
432
+ 'a subagent.failed meta event should be recorded under the parent session',
433
+ );
434
+ assert.not.ok(
435
+ getMetaEvents('').some((e) => e.type === 'subagent.failed'),
436
+ 'the failure must not be orphaned in the child default session bucket',
437
+ );
438
+ },
439
+ );
440
+
441
+ subagent(
442
+ 'defaults to { ok: false, reason: "max_iterations" } when the sub-agent ends without completing',
443
+ async () => {
444
+ const sessionKey = 'subagent-default-fail-test';
445
+ clearMetaEventRegistry();
446
+
447
+ let outcome: unknown;
448
+ const worker = agent({
449
+ name: 'worker',
450
+ toolDefinitions: [def('noop')],
451
+ toolHandlers: { noop: async () => 'ok' },
452
+ });
453
+ const parent = delegatingParent(worker, (o) => {
454
+ outcome = o;
455
+ });
456
+ // No script for the worker turn → it returns a plain-text reply and ends
457
+ // without ever calling a completion tool (the child records no explicit
458
+ // failure reason).
459
+ const provider = scriptedProvider([callsTool('delegate', 'd1')]);
460
+
461
+ await makeDriver(parent, provider, sessionKey).sendMessage('go');
462
+
463
+ assert.equal(outcome, { ok: false, reason: 'max_iterations' });
464
+ // Even the defensive default is reported to the parent session — this is the
465
+ // only telemetry path when the child recorded no explicit failure.
466
+ assert.ok(
467
+ getMetaEvents(sessionKey).some(
468
+ (e) => e.type === 'subagent.failed' && e.detail?.reason === 'max_iterations',
469
+ ),
470
+ 'the default failure should still record a subagent.failed meta event',
471
+ );
472
+ },
473
+ );
474
+
475
+ subagent(
476
+ "forwards the sub-agent's turns onto the parent timeline, numbered under the activating turn",
477
+ async () => {
478
+ const parent = delegatingParent(completingWorker({ done: true }), () => {});
479
+ const provider = scriptedProvider([callsTool('delegate', 'd1'), callsTool('finish', 'f1')]);
480
+ const driver = makeDriver(parent, provider);
481
+
482
+ await driver.sendMessage('go');
483
+
484
+ const snaps = driver.getTurnSnapshots();
485
+ // Parent turn 0 activated the sub-agent, so the worker's single turn is "0-1".
486
+ const childSnap = snaps.find((s) => s.turnIndex === '0-1');
487
+ assert.ok(childSnap, 'the sub-agent\'s turn should be forwarded as "0-1"');
488
+ assert.is(childSnap!.agentName, 'worker', 'the forwarded snapshot keeps the sub-agent name');
489
+ assert.ok(childSnap!.toolNames.includes('finish'), 'and records the tools the sub-agent saw');
490
+ // The parent's own turns stay numeric.
491
+ assert.ok(
492
+ snaps.some((s) => s.turnIndex === '0'),
493
+ 'the activating parent turn is present as a bare string counter',
494
+ );
495
+ },
496
+ );
497
+
498
+ subagent.run();