@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.
- package/dist/ai-assistant.api.json +74 -3
- package/dist/ai-assistant.d.ts +62 -4
- package/dist/dts/components/chat-driver/chat-driver.d.ts +60 -3
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/main/main.d.ts +1 -1
- package/dist/dts/state/debug-event-log.d.ts +1 -1
- package/dist/dts/state/debug-event-log.d.ts.map +1 -1
- package/dist/esm/components/chat-driver/chat-driver.js +215 -43
- package/dist/esm/components/chat-driver/chat-driver.test.js +134 -4
- package/dist/esm/main/main.js +1 -1
- package/dist/esm/state/debug-event-log.js +2 -1
- package/docs/migration-GENC-1312.md +176 -0
- package/docs/sub_agent.md +35 -15
- package/package.json +16 -16
- package/src/components/chat-driver/chat-driver.test.ts +187 -4
- package/src/components/chat-driver/chat-driver.ts +247 -51
- package/src/main/main.ts +1 -1
- package/src/state/debug-event-log.ts +3 -1
|
@@ -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
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
The return type of `requestSubAgent<T>` is:
|
|
130
134
|
|
|
131
135
|
```ts
|
|
132
|
-
|
|
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 (
|
|
137
|
-
//
|
|
138
|
-
|
|
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.
|
|
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.
|
|
68
|
-
"@genesislcap/genx": "14.452.
|
|
69
|
-
"@genesislcap/rollup-builder": "14.452.
|
|
70
|
-
"@genesislcap/ts-builder": "14.452.
|
|
71
|
-
"@genesislcap/uvu-playwright-builder": "14.452.
|
|
72
|
-
"@genesislcap/vite-builder": "14.452.
|
|
73
|
-
"@genesislcap/webpack-builder": "14.452.
|
|
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.
|
|
79
|
-
"@genesislcap/foundation-logger": "14.452.
|
|
80
|
-
"@genesislcap/foundation-redux": "14.452.
|
|
81
|
-
"@genesislcap/foundation-ui": "14.452.
|
|
82
|
-
"@genesislcap/foundation-utils": "14.452.
|
|
83
|
-
"@genesislcap/rapid-design-system": "14.452.
|
|
84
|
-
"@genesislcap/web-core": "14.452.
|
|
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": "
|
|
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
|
|
285
|
-
// trips
|
|
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:
|
|
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
|
-
|
|
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();
|