@agentclientprotocol/claude-agent-acp 0.89.2-preview.9 → 0.90.1-preview.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/README.md +56 -8
- package/dist/acp-agent.d.ts +50 -1
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +493 -147
- package/dist/acp-subagents.d.ts +16 -6
- package/dist/acp-subagents.d.ts.map +1 -1
- package/dist/acp-subagents.js +8 -2
- package/dist/async-tasks.d.ts +4 -1
- package/dist/async-tasks.d.ts.map +1 -1
- package/dist/auth-status.d.ts +2 -2
- package/dist/auth-status.d.ts.map +1 -1
- package/dist/auth-status.js +18 -4
- package/dist/exit-plan.d.ts +17 -0
- package/dist/exit-plan.d.ts.map +1 -1
- package/dist/exit-plan.js +34 -0
- package/dist/native-subagents.d.ts +228 -11
- package/dist/native-subagents.d.ts.map +1 -1
- package/dist/native-subagents.js +489 -40
- package/dist/settings.d.ts +6 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +13 -0
- package/dist/subagent-history.d.ts +44 -2
- package/dist/subagent-history.d.ts.map +1 -1
- package/dist/subagent-history.js +116 -10
- package/dist/subagent-replay.d.ts +108 -0
- package/dist/subagent-replay.d.ts.map +1 -0
- package/dist/subagent-replay.js +261 -0
- package/dist/tool-calls/background.d.ts +9 -0
- package/dist/tool-calls/background.d.ts.map +1 -1
- package/dist/tool-calls/background.js +25 -0
- package/dist/tool-calls/renderer.d.ts +30 -9
- package/dist/tool-calls/renderer.d.ts.map +1 -1
- package/dist/tool-calls/renderer.js +48 -22
- package/dist/tool-calls/reporters/bash.d.ts +7 -0
- package/dist/tool-calls/reporters/bash.d.ts.map +1 -1
- package/dist/tool-calls/reporters/bash.js +16 -1
- package/dist/tool-calls/reporters/search.d.ts.map +1 -1
- package/dist/tool-calls/reporters/search.js +20 -2
- package/dist/v2/agent.d.ts.map +1 -1
- package/dist/v2/agent.js +45 -12
- package/dist/v2/initialize.d.ts.map +1 -1
- package/dist/v2/initialize.js +10 -1
- package/dist/v2/session-update.d.ts +11 -0
- package/dist/v2/session-update.d.ts.map +1 -1
- package/dist/v2/session-update.js +67 -0
- package/dist/v2/session.d.ts +3 -12
- package/dist/v2/session.d.ts.map +1 -1
- package/dist/v2/session.js +5 -18
- package/dist/v2/tool-call-meta.d.ts +44 -0
- package/dist/v2/tool-call-meta.d.ts.map +1 -0
- package/dist/v2/tool-call-meta.js +72 -0
- package/package.json +9 -9
package/dist/native-subagents.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RequestError } from "@agentclientprotocol/sdk";
|
|
1
2
|
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
|
|
2
3
|
const MAX_PENDING_PARENTS = 64;
|
|
3
4
|
const MAX_PENDING_UPDATES = 256;
|
|
@@ -13,7 +14,10 @@ export class NativeSubagentRuntime {
|
|
|
13
14
|
session;
|
|
14
15
|
publish;
|
|
15
16
|
logger;
|
|
17
|
+
lineage;
|
|
16
18
|
enabled;
|
|
19
|
+
/** The form of the child sessions; see {@link SubagentForm}. */
|
|
20
|
+
form;
|
|
17
21
|
children;
|
|
18
22
|
taskByToolUse;
|
|
19
23
|
parentByToolUse;
|
|
@@ -27,15 +31,23 @@ export class NativeSubagentRuntime {
|
|
|
27
31
|
*/
|
|
28
32
|
childByToolCall = new Map();
|
|
29
33
|
taskFinishPromises = new Map();
|
|
34
|
+
/** The RFD form: the session that made each SendMessage call. */
|
|
35
|
+
senderByToolUse = new Map();
|
|
30
36
|
generationByTaskId = new Map();
|
|
31
37
|
pending = new Map();
|
|
32
38
|
pendingCount = 0;
|
|
33
|
-
|
|
39
|
+
/**
|
|
40
|
+
* `form` is undefined for a client without native subagent sessions. The RFD
|
|
41
|
+
* form restores an agent from before a load or resume with `lineage`.
|
|
42
|
+
*/
|
|
43
|
+
constructor(form, rootSessionId, session, publish, logger, lineage) {
|
|
34
44
|
this.rootSessionId = rootSessionId;
|
|
35
45
|
this.session = session;
|
|
36
46
|
this.publish = publish;
|
|
37
47
|
this.logger = logger;
|
|
38
|
-
this.
|
|
48
|
+
this.lineage = lineage;
|
|
49
|
+
this.form = form;
|
|
50
|
+
this.enabled = form !== undefined;
|
|
39
51
|
this.children = session.nativeSubagentsByTaskId ??= new Map();
|
|
40
52
|
this.taskByToolUse = session.nativeSubagentTaskIdByToolUseId ??= new Map();
|
|
41
53
|
this.parentByToolUse = session.nativeSubagentParentByToolUseId ??= new Map();
|
|
@@ -46,6 +58,12 @@ export class NativeSubagentRuntime {
|
|
|
46
58
|
}
|
|
47
59
|
}
|
|
48
60
|
async route(notification, deliver, forcedSessionId) {
|
|
61
|
+
const routed = await this.routeUpdate(notification, deliver, forcedSessionId);
|
|
62
|
+
if (routed && this.form === "rfd")
|
|
63
|
+
this.rememberSendMessage(routed);
|
|
64
|
+
return routed;
|
|
65
|
+
}
|
|
66
|
+
async routeUpdate(notification, deliver, forcedSessionId) {
|
|
49
67
|
const { update } = notification;
|
|
50
68
|
const claudeMeta = update._meta?.claudeCode;
|
|
51
69
|
const isControl = isNativeSubagentControlUpdate(update);
|
|
@@ -60,28 +78,43 @@ export class NativeSubagentRuntime {
|
|
|
60
78
|
if (identity) {
|
|
61
79
|
this.identityByToolUse.set(toolCallId, mergeSubagentIdentity(this.identityByToolUse.get(toolCallId), identity));
|
|
62
80
|
}
|
|
81
|
+
const parentChild = claudeMeta?.parentToolUseId
|
|
82
|
+
? this.childByParentToolUse.get(claudeMeta.parentToolUseId)
|
|
83
|
+
: undefined;
|
|
63
84
|
const parentSessionId = claudeMeta?.parentToolUseId
|
|
64
|
-
?
|
|
85
|
+
? parentChild?.sessionId
|
|
65
86
|
: this.rootSessionId;
|
|
66
|
-
|
|
87
|
+
// The RFD form: a call made inside a child that is not announced yet
|
|
88
|
+
// establishes no parent, because the RFD forbids announcing its child
|
|
89
|
+
// under the root or before that child. The update is buffered below
|
|
90
|
+
// under the child's tool use, and routed again, with the child as the
|
|
91
|
+
// parent, once the child is announced.
|
|
92
|
+
const parentPending = this.form === "rfd" && !!claudeMeta?.parentToolUseId && !parentChild?.announced;
|
|
67
93
|
const child = this.childByParentToolUse.get(toolCallId);
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
await
|
|
94
|
+
if (!parentPending) {
|
|
95
|
+
this.parentByToolUse.set(toolCallId, parentSessionId ?? this.rootSessionId);
|
|
96
|
+
if (child && !child.announced) {
|
|
97
|
+
child.parentSessionId = parentSessionId ?? this.rootSessionId;
|
|
98
|
+
applySubagentIdentity(child, this.identityByToolUse.get(toolCallId));
|
|
99
|
+
await this.announce(child);
|
|
100
|
+
for (const pending of this.takePending(toolCallId))
|
|
101
|
+
await deliver(pending);
|
|
102
|
+
}
|
|
74
103
|
}
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
104
|
+
if (this.form === "air") {
|
|
105
|
+
if (!child && isFailedToolCallUpdate(update)) {
|
|
106
|
+
this.takePending(toolCallId);
|
|
107
|
+
const initial = this.controlByToolUse.get(toolCallId);
|
|
108
|
+
this.cleanupControl(toolCallId);
|
|
109
|
+
if (forcedSessionId) {
|
|
110
|
+
return { ...notification, sessionId: forcedSessionId };
|
|
111
|
+
}
|
|
112
|
+
return failedControlFallback(initial, notification, parentSessionId ?? this.rootSessionId);
|
|
81
113
|
}
|
|
82
|
-
return
|
|
114
|
+
return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
|
|
83
115
|
}
|
|
84
|
-
|
|
116
|
+
// The RFD form: the Agent/Task tool call is the parent's record of the
|
|
117
|
+
// delegation, so it stays a tool call of the session that made it.
|
|
85
118
|
}
|
|
86
119
|
const toolCallId = update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update"
|
|
87
120
|
? update.toolCallId
|
|
@@ -112,6 +145,16 @@ export class NativeSubagentRuntime {
|
|
|
112
145
|
async taskStarted(task, deliver) {
|
|
113
146
|
if (!this.enabled)
|
|
114
147
|
return;
|
|
148
|
+
const known = this.form === "rfd"
|
|
149
|
+
? (this.children.get(task.taskId) ??
|
|
150
|
+
(await this.restore(task.taskId, task.toolUseId ?? undefined, deliver)))
|
|
151
|
+
: undefined;
|
|
152
|
+
if (known) {
|
|
153
|
+
// A child keeps its session for every delegation to it. The SDK starts
|
|
154
|
+
// a resumed agent again under the id of the call that resumed it.
|
|
155
|
+
await this.resumeChild(known, task.taskId, promptText(task.prompt), task.toolUseId ?? undefined, deliver);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
115
158
|
if (!task.subagentType) {
|
|
116
159
|
if (task.toolUseId) {
|
|
117
160
|
this.takePending(task.toolUseId);
|
|
@@ -135,6 +178,7 @@ export class NativeSubagentRuntime {
|
|
|
135
178
|
name: subagentDisplayName(identity?.name, identity?.description ?? task.description, identity?.subagentType ?? task.subagentType, task.taskId),
|
|
136
179
|
task: subagentDescription(identity?.prompt ?? task.prompt, identity?.description ?? task.description),
|
|
137
180
|
...promptField(promptText(task.prompt) ?? identity?.prompt),
|
|
181
|
+
...rfdLabels(identity?.name, identity?.subagentType ?? task.subagentType, identity?.description ?? task.description),
|
|
138
182
|
}, !!knownParentSessionId || !task.toolUseId, deliver);
|
|
139
183
|
}
|
|
140
184
|
/**
|
|
@@ -143,6 +187,10 @@ export class NativeSubagentRuntime {
|
|
|
143
187
|
* running `task_updated` patch or a SendMessage `resumedAgentId` is the
|
|
144
188
|
* signal. A child that did not finish is not changed. The `prompt` is the
|
|
145
189
|
* SendMessage text that resumed the child, when the adapter knows it.
|
|
190
|
+
*
|
|
191
|
+
* In the RFD form, the running patch only reports that the child runs: its
|
|
192
|
+
* prompt is a guess (the latest SendMessage to the agent), and the
|
|
193
|
+
* `task_started` or the SendMessage result carries the delegation.
|
|
146
194
|
*/
|
|
147
195
|
async taskResumed(taskId, deliver, prompt) {
|
|
148
196
|
if (!this.enabled)
|
|
@@ -153,6 +201,10 @@ export class NativeSubagentRuntime {
|
|
|
153
201
|
const finishing = this.taskFinishPromises.get(taskId) ?? previous.terminalPromise;
|
|
154
202
|
if (finishing)
|
|
155
203
|
await finishing.catch(() => { });
|
|
204
|
+
if (this.form === "rfd") {
|
|
205
|
+
await this.resumeChild(previous, taskId, undefined, undefined, deliver);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
156
208
|
if (this.children.get(taskId) !== previous || previous.terminalState === undefined)
|
|
157
209
|
return;
|
|
158
210
|
await this.openGeneration(taskId, previous, {
|
|
@@ -163,26 +215,97 @@ export class NativeSubagentRuntime {
|
|
|
163
215
|
...promptField(promptText(prompt)),
|
|
164
216
|
}, true, deliver);
|
|
165
217
|
}
|
|
166
|
-
|
|
218
|
+
/**
|
|
219
|
+
* The SDK reports in a SendMessage result that the call `toolUseId`, with
|
|
220
|
+
* the message `prompt`, resumed the agent `taskId`. AIR's draft treats it as
|
|
221
|
+
* any resume signal. In the RFD form, the call is the delegation, which the
|
|
222
|
+
* child takes unless its run already ended: the result can arrive after it.
|
|
223
|
+
*/
|
|
224
|
+
async sendMessageResumed(taskId, deliver, prompt, toolUseId) {
|
|
225
|
+
if (this.form !== "rfd")
|
|
226
|
+
return this.taskResumed(taskId, deliver, prompt);
|
|
227
|
+
const child = this.children.get(taskId) ?? (await this.restore(taskId, toolUseId, deliver));
|
|
228
|
+
if (!child)
|
|
229
|
+
return;
|
|
230
|
+
const finishing = this.taskFinishPromises.get(taskId);
|
|
231
|
+
if (finishing)
|
|
232
|
+
await finishing.catch(() => { });
|
|
233
|
+
if (this.isEndedDelegation(taskId, toolUseId))
|
|
234
|
+
return;
|
|
235
|
+
await this.resumeChild(child, taskId, promptText(prompt), toolUseId, deliver);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The RFD form: whether the run of the call `toolUseId` that delegated to
|
|
239
|
+
* the child `taskId` already ended. AIR's draft has no such runs.
|
|
240
|
+
*/
|
|
241
|
+
isEndedDelegation(taskId, toolUseId) {
|
|
242
|
+
if (this.form !== "rfd" || !toolUseId)
|
|
243
|
+
return false;
|
|
244
|
+
return this.children.get(taskId)?.endedRuns?.has(toolUseId) ?? false;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The RFD form: whether `toolUseId` is an earlier delegation of the child
|
|
248
|
+
* `taskId`, whose end does not end the current one. AIR's draft has no
|
|
249
|
+
* such delegations.
|
|
250
|
+
*/
|
|
251
|
+
isStaleEnd(taskId, toolUseId) {
|
|
252
|
+
if (this.form !== "rfd" || !toolUseId)
|
|
253
|
+
return false;
|
|
254
|
+
const child = this.children.get(taskId);
|
|
255
|
+
return (!!child &&
|
|
256
|
+
child.parentToolUseId !== undefined &&
|
|
257
|
+
toolUseId !== child.parentToolUseId &&
|
|
258
|
+
this.childByParentToolUse.get(toolUseId) === child);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Reports that the SDK ended the task's work. `end` has what the SDK says
|
|
262
|
+
* about it: its text for a failure, and its reason for an unusual end.
|
|
263
|
+
*/
|
|
264
|
+
async finishTask(taskId, status, deliver, toolUseId, end) {
|
|
167
265
|
if (!this.enabled)
|
|
168
266
|
return;
|
|
169
267
|
const state = nativeSubagentState(status);
|
|
170
268
|
const child = toolUseId ? this.childByParentToolUse.get(toolUseId) : this.children.get(taskId);
|
|
171
269
|
if (child && toolUseId && this.taskByToolUse.get(toolUseId) !== taskId)
|
|
172
270
|
return;
|
|
173
|
-
if (!state || !child
|
|
271
|
+
if (!state || !child)
|
|
174
272
|
return;
|
|
273
|
+
if (this.form === "rfd") {
|
|
274
|
+
if (this.isStaleEnd(taskId, toolUseId))
|
|
275
|
+
return;
|
|
276
|
+
if (!child.announced && !child.announcePromise) {
|
|
277
|
+
this.withdraw(taskId, child);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
175
281
|
const existing = this.taskFinishPromises.get(taskId);
|
|
176
|
-
if (existing)
|
|
177
|
-
|
|
282
|
+
if (existing) {
|
|
283
|
+
await existing;
|
|
284
|
+
if (this.form === "rfd")
|
|
285
|
+
await this.supplyError(child, state, end?.error);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (this.form === "air" ? child.terminalState !== undefined : child.workState === "idle") {
|
|
289
|
+
if (this.form === "rfd")
|
|
290
|
+
await this.supplyError(child, state, end?.error);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
178
293
|
const finish = Promise.resolve().then(async () => {
|
|
179
294
|
try {
|
|
180
|
-
await
|
|
295
|
+
await this.announce(child);
|
|
181
296
|
if (child.parentToolUseId) {
|
|
182
297
|
for (const pending of this.takePending(child.parentToolUseId))
|
|
183
298
|
await deliver(pending);
|
|
184
299
|
}
|
|
185
|
-
|
|
300
|
+
if (this.form === "rfd") {
|
|
301
|
+
await this.reportState(child, rfdEndState(state, end));
|
|
302
|
+
child.missingError = state === "failed" && !end?.error && !end?.unexplained;
|
|
303
|
+
if (child.parentToolUseId)
|
|
304
|
+
(child.endedRuns ??= new Set()).add(child.parentToolUseId);
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
await finishNativeSubagent(this.session, taskId, state, this.publish);
|
|
308
|
+
}
|
|
186
309
|
}
|
|
187
310
|
finally {
|
|
188
311
|
if (child.parentToolUseId) {
|
|
@@ -199,12 +322,20 @@ export class NativeSubagentRuntime {
|
|
|
199
322
|
this.taskFinishPromises.delete(taskId);
|
|
200
323
|
}
|
|
201
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* Ends every child that still runs, because the stream ended or the
|
|
327
|
+
* conversation was reset. AIR's draft reports `state` for each. In the RFD
|
|
328
|
+
* form the cause of each child's end is unknown, so it is idle with no stop
|
|
329
|
+
* reason: a child that a cancel stopped already reported it from the SDK's
|
|
330
|
+
* events, and a background child that outlived the cancel was not cancelled.
|
|
331
|
+
*/
|
|
202
332
|
async finishAll(state, deliver) {
|
|
203
333
|
const errors = [];
|
|
334
|
+
const end = this.form === "rfd" ? { unexplained: true } : undefined;
|
|
204
335
|
try {
|
|
205
336
|
for (const taskId of [...this.children.keys()].reverse()) {
|
|
206
337
|
try {
|
|
207
|
-
await this.finishTask(taskId, state, deliver);
|
|
338
|
+
await this.finishTask(taskId, state, deliver, undefined, end);
|
|
208
339
|
}
|
|
209
340
|
catch (error) {
|
|
210
341
|
errors.push(error);
|
|
@@ -223,15 +354,182 @@ export class NativeSubagentRuntime {
|
|
|
223
354
|
if (errors.length > 1)
|
|
224
355
|
throw new AggregateError(errors, "Failed to finish native subagents");
|
|
225
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* The parent session got `session/cancel`. AIR's draft ends every child as
|
|
359
|
+
* cancelled here. The RFD form reports nothing: acknowledging a cancel is no
|
|
360
|
+
* evidence that a child stopped. Claude Code's interrupt stops the
|
|
361
|
+
* foreground children that the turn waits on and, currently, background
|
|
362
|
+
* children too, and the SDK's task events report each as cancelled. A child
|
|
363
|
+
* that keeps running keeps its open requests
|
|
364
|
+
* (agentclientprotocol/agent-client-protocol#2308).
|
|
365
|
+
*/
|
|
366
|
+
async parentCancelled(deliver) {
|
|
367
|
+
if (this.form === "air")
|
|
368
|
+
await this.finishAll("cancelled", deliver);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Runs `request`, a permission or question request of the session
|
|
372
|
+
* `sessionId`. In the RFD form, a child waiting on it is `requires_action`
|
|
373
|
+
* until its last open request ends.
|
|
374
|
+
*/
|
|
375
|
+
async awaitingUser(sessionId, request) {
|
|
376
|
+
const child = this.form === "rfd" ? this.childOfSession(sessionId) : undefined;
|
|
377
|
+
if (!child?.announced)
|
|
378
|
+
return request();
|
|
379
|
+
child.openRequests = (child.openRequests ?? 0) + 1;
|
|
380
|
+
if (child.openRequests === 1 && child.workState === "running") {
|
|
381
|
+
await this.reportState(child, { state: "requires_action" }).catch((error) => this.logger.log(`Failed to report that subagent ${child.sessionId} waits: ${error}`));
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
return await request();
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
child.openRequests = (child.openRequests ?? 1) - 1;
|
|
388
|
+
if (child.openRequests === 0 && child.workState === "requires_action") {
|
|
389
|
+
await this.reportState(child, { state: "running" }).catch((error) => this.logger.log(`Failed to report that subagent ${child.sessionId} runs: ${error}`));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
226
393
|
discardPending(parentToolUseId) {
|
|
227
394
|
this.takePending(parentToolUseId);
|
|
228
395
|
}
|
|
229
396
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
397
|
+
* The RFD form: restores the agent `agentId` when the SendMessage call
|
|
398
|
+
* `toolUseId` resumes it and the runtime does not know it, because it ran
|
|
399
|
+
* before the session was loaded or resumed. A new launch is no resume: its
|
|
400
|
+
* call is an Agent or Task call.
|
|
401
|
+
*
|
|
402
|
+
* The agent keeps its session id from before, its agent id. The session's
|
|
403
|
+
* subagent history (see {@link SubagentLineage}) gives the call that
|
|
404
|
+
* launched it and its ancestors. The launch call is its delegation again:
|
|
405
|
+
* Claude Code sends the resumed run's messages under that call's id. Before the agent's traffic, the RFD requires its complete
|
|
406
|
+
* ancestor chain, parent before child. So each ancestor that the client was
|
|
407
|
+
* not told about in this connection is announced, with no work state since
|
|
408
|
+
* nothing says that it runs; then the agent, as running. The RFD forbids
|
|
409
|
+
* guessing a parent, so an agent that the history cannot place stays
|
|
410
|
+
* unexposed, as before.
|
|
411
|
+
*/
|
|
412
|
+
async restore(agentId, toolUseId, deliver) {
|
|
413
|
+
if (!this.lineage || !toolUseId || !this.senderByToolUse.has(toolUseId))
|
|
414
|
+
return undefined;
|
|
415
|
+
const lineage = await this.lineage(agentId).catch((error) => {
|
|
416
|
+
this.logger.log(`Session ${this.rootSessionId}: failed to read the history of subagent ${agentId}: ${error}`);
|
|
417
|
+
return [];
|
|
418
|
+
});
|
|
419
|
+
for (const entry of lineage) {
|
|
420
|
+
let child = this.children.get(entry.agentId);
|
|
421
|
+
if (!child) {
|
|
422
|
+
child = {
|
|
423
|
+
sessionId: entry.agentId,
|
|
424
|
+
parentSessionId: entry.parentAgentId
|
|
425
|
+
? (this.children.get(entry.parentAgentId)?.sessionId ?? entry.parentAgentId)
|
|
426
|
+
: this.rootSessionId,
|
|
427
|
+
parentToolUseId: entry.launchId,
|
|
428
|
+
name: subagentDisplayName(entry.name, entry.description, entry.agentType, entry.agentId),
|
|
429
|
+
task: subagentDescription(undefined, entry.description),
|
|
430
|
+
...rfdLabels(entry.name, entry.agentType, entry.description),
|
|
431
|
+
};
|
|
432
|
+
this.children.set(entry.agentId, child);
|
|
433
|
+
this.taskByToolUse.set(entry.launchId, entry.agentId);
|
|
434
|
+
this.childByParentToolUse.set(entry.launchId, child);
|
|
435
|
+
}
|
|
436
|
+
if (child.announced)
|
|
437
|
+
continue;
|
|
438
|
+
if (entry.agentId === agentId) {
|
|
439
|
+
await this.announce(child);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
await this.publish({
|
|
443
|
+
sessionId: child.parentSessionId,
|
|
444
|
+
update: {
|
|
445
|
+
sessionUpdate: "subagent_update",
|
|
446
|
+
sessionId: child.sessionId,
|
|
447
|
+
...(child.title ? { title: child.title } : {}),
|
|
448
|
+
...(child.description ? { description: child.description } : {}),
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
child.announced = true;
|
|
452
|
+
}
|
|
453
|
+
for (const pending of this.takePending(entry.launchId))
|
|
454
|
+
await deliver(pending);
|
|
455
|
+
}
|
|
456
|
+
return this.children.get(agentId);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* The RFD form: the child runs again, and keeps its session. `toolUseId` is
|
|
460
|
+
* the call that delegated, usually a SendMessage: the SDK runs the resumed
|
|
461
|
+
* work under its id, so it becomes the child's current delegation, whose
|
|
462
|
+
* end ends the run, and its prompt is the message with that id. A child
|
|
463
|
+
* that is already working only takes the delegation; a prompt already in
|
|
464
|
+
* its transcript is not sent again. A child that was never announced is
|
|
465
|
+
* not changed.
|
|
466
|
+
*/
|
|
467
|
+
async resumeChild(child, taskId, prompt, toolUseId, deliver) {
|
|
468
|
+
if (!child.announced)
|
|
469
|
+
return;
|
|
470
|
+
if (toolUseId && toolUseId !== child.parentToolUseId) {
|
|
471
|
+
this.taskByToolUse.set(toolUseId, taskId);
|
|
472
|
+
this.childByParentToolUse.set(toolUseId, child);
|
|
473
|
+
child.parentToolUseId = toolUseId;
|
|
474
|
+
}
|
|
475
|
+
if (!isWorking(child)) {
|
|
476
|
+
await this.reportState(child, {
|
|
477
|
+
state: child.openRequests ? "requires_action" : "running",
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (prompt && toolUseId) {
|
|
481
|
+
await publishPrompt(child, prompt, toolUseId, this.senderByToolUse.get(toolUseId), this.publish);
|
|
482
|
+
}
|
|
483
|
+
for (const pending of toolUseId ? this.takePending(toolUseId) : [])
|
|
484
|
+
await deliver(pending);
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* The RFD form: a child that ends before its parent is known stays
|
|
488
|
+
* unexposed, since the RFD forbids guessing its parent. Its buffered updates
|
|
489
|
+
* are dropped, and it is forgotten, so a late frame does not announce it.
|
|
490
|
+
* (Buffering until that frame would also meet the RFD, but the frame comes
|
|
491
|
+
* before the task in the SDK's order, so a child ending without it is rare.)
|
|
492
|
+
*/
|
|
493
|
+
withdraw(taskId, child) {
|
|
494
|
+
this.children.delete(taskId);
|
|
495
|
+
if (child.parentToolUseId) {
|
|
496
|
+
this.takePending(child.parentToolUseId);
|
|
497
|
+
this.cleanupControl(child.parentToolUseId);
|
|
498
|
+
this.childByParentToolUse.delete(child.parentToolUseId);
|
|
499
|
+
this.taskByToolUse.delete(child.parentToolUseId);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* The RFD form: a later event of a failure can carry the SDK's error text
|
|
504
|
+
* that the reported idle snapshot lacks.
|
|
505
|
+
*/
|
|
506
|
+
async supplyError(child, state, error) {
|
|
507
|
+
if (state !== "failed" || !error || !child.missingError)
|
|
508
|
+
return;
|
|
509
|
+
await this.reportState(child, rfdEndState("failed", { error }));
|
|
510
|
+
}
|
|
511
|
+
/** Announces `child` in the client's form. A runtime without one never does. */
|
|
512
|
+
async announce(child) {
|
|
513
|
+
if (this.form)
|
|
514
|
+
await announceNativeSubagent(child, this.publish, this.form);
|
|
515
|
+
}
|
|
516
|
+
/** The RFD form: publishes the work state of `child` on its parent. */
|
|
517
|
+
async reportState(child, state) {
|
|
518
|
+
child.workState = state.state;
|
|
519
|
+
child.missingError = false;
|
|
520
|
+
await this.publish({
|
|
521
|
+
sessionId: child.parentSessionId,
|
|
522
|
+
update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state },
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Routes an update to the child session. It never goes to the root session:
|
|
527
|
+
* in AIR's draft, an update of a finished child is dropped.
|
|
232
528
|
*/
|
|
233
529
|
toChild(child, notification, toolCallId) {
|
|
234
|
-
|
|
530
|
+
// AIR's draft ends a child for good. In the RFD form an idle child is
|
|
531
|
+
// reusable, and its background work can still report.
|
|
532
|
+
if (this.form === "air" && (child.terminalState !== undefined || child.terminalPromise)) {
|
|
235
533
|
this.logger.log(`Session ${this.rootSessionId}: ignoring late update for terminal subagent ${child.sessionId}`);
|
|
236
534
|
return null;
|
|
237
535
|
}
|
|
@@ -245,10 +543,11 @@ export class NativeSubagentRuntime {
|
|
|
245
543
|
* tool call when the work started. An async task can outlive that child: a
|
|
246
544
|
* subagent can start a background command and end its turn. So the route
|
|
247
545
|
* still sends `async_task_spawned`, `async_task_progress` and
|
|
248
|
-
* `async_task_state_update` to the child generation after it finished.
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
546
|
+
* `async_task_state_update` to the child generation after it finished. In
|
|
547
|
+
* AIR's draft, the route drops every other update after the child finished;
|
|
548
|
+
* in the RFD form, an idle child gets them all. `undefined` means that the
|
|
549
|
+
* root session owns the tool call. `eagerSessionId` is the session where a
|
|
550
|
+
* permission request created the tool call before the stream routed it.
|
|
252
551
|
*/
|
|
253
552
|
routeOfToolCall(toolCallId, eagerSessionId) {
|
|
254
553
|
if (!this.enabled)
|
|
@@ -260,6 +559,25 @@ export class NativeSubagentRuntime {
|
|
|
260
559
|
? { ...notification, sessionId: owner.sessionId }
|
|
261
560
|
: this.toChild(owner, notification, undefined)));
|
|
262
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* The RFD form: remembers the session that made a SendMessage call, the
|
|
564
|
+
* sender of the prompt when the call resumes a child.
|
|
565
|
+
*/
|
|
566
|
+
rememberSendMessage(notification) {
|
|
567
|
+
const { update } = notification;
|
|
568
|
+
if (update.sessionUpdate !== "tool_call")
|
|
569
|
+
return;
|
|
570
|
+
const claudeMeta = update._meta?.claudeCode;
|
|
571
|
+
if (claudeMeta?.toolName !== "SendMessage")
|
|
572
|
+
return;
|
|
573
|
+
this.senderByToolUse.delete(update.toolCallId);
|
|
574
|
+
this.senderByToolUse.set(update.toolCallId, notification.sessionId);
|
|
575
|
+
if (this.senderByToolUse.size > MAX_CHILD_TOOL_CALLS) {
|
|
576
|
+
const oldest = this.senderByToolUse.keys().next().value;
|
|
577
|
+
if (oldest !== undefined)
|
|
578
|
+
this.senderByToolUse.delete(oldest);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
263
581
|
rememberToolCallOwner(toolCallId, child) {
|
|
264
582
|
this.childByToolCall.delete(toolCallId);
|
|
265
583
|
this.childByToolCall.set(toolCallId, child);
|
|
@@ -288,6 +606,7 @@ export class NativeSubagentRuntime {
|
|
|
288
606
|
this.controlByToolUse.clear();
|
|
289
607
|
this.childByParentToolUse.clear();
|
|
290
608
|
this.taskFinishPromises.clear();
|
|
609
|
+
this.senderByToolUse.clear();
|
|
291
610
|
this.generationByTaskId.clear();
|
|
292
611
|
this.pending.clear();
|
|
293
612
|
this.pendingCount = 0;
|
|
@@ -353,10 +672,20 @@ export class NativeSubagentRuntime {
|
|
|
353
672
|
}
|
|
354
673
|
if (!announce)
|
|
355
674
|
return;
|
|
356
|
-
await
|
|
675
|
+
await this.announce(child);
|
|
357
676
|
for (const pending of toolUseId ? this.takePending(toolUseId) : [])
|
|
358
677
|
await deliver(pending);
|
|
359
678
|
}
|
|
679
|
+
/**
|
|
680
|
+
* The ACP session id of a child: the SDK agent id, and in AIR's draft a
|
|
681
|
+
* generation suffix for each resume. The RFD needs it unique within the
|
|
682
|
+
* connection and never under two parents. Agent ids are random, and an
|
|
683
|
+
* agent keeps its parent, so only a fork could repeat one under another
|
|
684
|
+
* root. That holds today because the SDK's `forkSession` copies no
|
|
685
|
+
* `subagents/` history, so a fork cannot resume an agent of the original.
|
|
686
|
+
* If a fork ever can, the RFD requires remapping the copied ids, for
|
|
687
|
+
* example by deriving the child id from the root session id.
|
|
688
|
+
*/
|
|
360
689
|
nextChildSessionId(taskId, previous) {
|
|
361
690
|
if (!previous) {
|
|
362
691
|
this.generationByTaskId.set(taskId, 1);
|
|
@@ -367,12 +696,37 @@ export class NativeSubagentRuntime {
|
|
|
367
696
|
return `${taskId}:generation:${generation}`;
|
|
368
697
|
}
|
|
369
698
|
}
|
|
370
|
-
|
|
699
|
+
/**
|
|
700
|
+
* Announces the child on its parent, once, before any update of the child.
|
|
701
|
+
* The RFD form announces it as running and then sends the prompt as the
|
|
702
|
+
* first message of the child. The child gets no capabilities, so the client
|
|
703
|
+
* may not cancel or otherwise change it.
|
|
704
|
+
*/
|
|
705
|
+
export async function announceNativeSubagent(child, publish, form) {
|
|
371
706
|
if (child.announced)
|
|
372
707
|
return;
|
|
373
708
|
if (child.announcePromise)
|
|
374
709
|
return child.announcePromise;
|
|
375
710
|
const announce = Promise.resolve().then(async () => {
|
|
711
|
+
if (form === "rfd") {
|
|
712
|
+
child.workState = "running";
|
|
713
|
+
await publish({
|
|
714
|
+
sessionId: child.parentSessionId,
|
|
715
|
+
update: {
|
|
716
|
+
sessionUpdate: "subagent_update",
|
|
717
|
+
sessionId: child.sessionId,
|
|
718
|
+
...(child.title ? { title: child.title } : {}),
|
|
719
|
+
...(child.description ? { description: child.description } : {}),
|
|
720
|
+
state: { state: "running" },
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
child.announced = true;
|
|
724
|
+
// The immediate parent made the Agent/Task call that launched the child.
|
|
725
|
+
if (child.prompt) {
|
|
726
|
+
await publishPrompt(child, child.prompt, child.parentToolUseId, child.parentSessionId, publish);
|
|
727
|
+
}
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
376
730
|
await publish({
|
|
377
731
|
sessionId: child.parentSessionId,
|
|
378
732
|
update: {
|
|
@@ -402,7 +756,7 @@ export async function finishNativeSubagent(session, taskId, state, publish) {
|
|
|
402
756
|
if (child.terminalPromise)
|
|
403
757
|
return child.terminalPromise;
|
|
404
758
|
const finish = Promise.resolve().then(async () => {
|
|
405
|
-
await announceNativeSubagent(child, publish);
|
|
759
|
+
await announceNativeSubagent(child, publish, "air");
|
|
406
760
|
await publish({
|
|
407
761
|
sessionId: child.parentSessionId,
|
|
408
762
|
update: {
|
|
@@ -422,6 +776,85 @@ export async function finishNativeSubagent(session, taskId, state, publish) {
|
|
|
422
776
|
child.terminalPromise = undefined;
|
|
423
777
|
}
|
|
424
778
|
}
|
|
779
|
+
/**
|
|
780
|
+
* The RFD form: a message to the child, in the child's transcript (its
|
|
781
|
+
* incoming view). Its id is the id of the tool use that sent it, the
|
|
782
|
+
* Agent/Task call or a SendMessage, so a prompt whose id is already in the
|
|
783
|
+
* transcript is the same delegation and is not sent again. `sender` is the
|
|
784
|
+
* session that made that call, when known.
|
|
785
|
+
*/
|
|
786
|
+
async function publishPrompt(child, prompt, toolUseId, sender, publish) {
|
|
787
|
+
child.promptIds ??= new Set();
|
|
788
|
+
const messageId = toolUseId ?? `${child.sessionId}:prompt:${child.promptIds.size + 1}`;
|
|
789
|
+
if (child.promptIds.has(messageId))
|
|
790
|
+
return;
|
|
791
|
+
child.promptIds.add(messageId);
|
|
792
|
+
await publish({
|
|
793
|
+
sessionId: child.sessionId,
|
|
794
|
+
update: {
|
|
795
|
+
sessionUpdate: "session_message",
|
|
796
|
+
messageId,
|
|
797
|
+
...(sender ? { senderSessionId: sender } : {}),
|
|
798
|
+
recipientSessionId: child.sessionId,
|
|
799
|
+
content: [{ type: "text", text: prompt }],
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
/** The RFD form: whether the child's foreground work is in progress. */
|
|
804
|
+
function isWorking(child) {
|
|
805
|
+
return child.workState === "running" || child.workState === "requires_action";
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* The RFD form: the work state at the end of a child's work.
|
|
809
|
+
*
|
|
810
|
+
* A failure is idle with the `error` stop reason and, when the SDK gives its
|
|
811
|
+
* text, the JSON-RPC `error`, as the RFD asks: a v1 child has no prompt
|
|
812
|
+
* response, so this is how a client learns that its work failed. The error is
|
|
813
|
+
* an internal error, as for a failed turn on the v2 surface.
|
|
814
|
+
*
|
|
815
|
+
* A task that a worker restart orphaned, or that was still running when the
|
|
816
|
+
* stream ended, is idle with no stop reason: its work ended, but the adapter
|
|
817
|
+
* did not see a cancellation or a failure, which the RFD forbids claiming.
|
|
818
|
+
*/
|
|
819
|
+
export function rfdEndState(state, end) {
|
|
820
|
+
if (end?.unexplained || end?.reason === "worker_restart")
|
|
821
|
+
return { state: "idle" };
|
|
822
|
+
switch (state) {
|
|
823
|
+
case "completed":
|
|
824
|
+
return { state: "idle", stopReason: "end_turn" };
|
|
825
|
+
case "cancelled":
|
|
826
|
+
return { state: "idle", stopReason: "cancelled" };
|
|
827
|
+
case "failed":
|
|
828
|
+
return {
|
|
829
|
+
state: "idle",
|
|
830
|
+
stopReason: "error",
|
|
831
|
+
...(end?.error
|
|
832
|
+
? { error: { code: RequestError.internalError().code, message: end.error } }
|
|
833
|
+
: {}),
|
|
834
|
+
};
|
|
835
|
+
default:
|
|
836
|
+
// "disconnected": the child can no longer be observed.
|
|
837
|
+
return { state: "unknown" };
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* The RFD form: the parent's labels for a child. The title is the agent's
|
|
842
|
+
* name, with its role after it when both are known, else the role. The
|
|
843
|
+
* description is the task's short description, never the prompt.
|
|
844
|
+
*/
|
|
845
|
+
export function rfdLabels(name, role, description) {
|
|
846
|
+
const agentName = nonBlankString(name);
|
|
847
|
+
const agentRole = nonBlankString(role);
|
|
848
|
+
const title = agentName && agentRole && agentName !== agentRole
|
|
849
|
+
? `${agentName} (${agentRole})`
|
|
850
|
+
: (agentName ?? agentRole);
|
|
851
|
+
const text = nonBlankString(description);
|
|
852
|
+
return {
|
|
853
|
+
...(title ? { title } : {}),
|
|
854
|
+
...(agentRole ? { role: agentRole } : {}),
|
|
855
|
+
...(text ? { description: text } : {}),
|
|
856
|
+
};
|
|
857
|
+
}
|
|
425
858
|
/**
|
|
426
859
|
* The agent id that a successful SendMessage result resumed. The SDK puts it
|
|
427
860
|
* in `tool_use_result.resumedAgentId` when a finished agent runs again.
|
|
@@ -438,22 +871,34 @@ export function resumedNativeSubagentId(toolUseResult) {
|
|
|
438
871
|
* carried the resume. Otherwise the latest SendMessage call to `agentId` counts.
|
|
439
872
|
*/
|
|
440
873
|
export function sendMessageResumePrompt(toolUses, agentId, resultToolUseIds = []) {
|
|
874
|
+
return sendMessageResume(toolUses, agentId, resultToolUseIds)?.text;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* The SendMessage call that resumed the agent `agentId`, and its text when it
|
|
878
|
+
* has one; see {@link sendMessageResumePrompt}. The call is known even
|
|
879
|
+
* without text, so a late result still names the delegation it reports.
|
|
880
|
+
*/
|
|
881
|
+
export function sendMessageResume(toolUses, agentId, resultToolUseIds = []) {
|
|
882
|
+
let withoutText;
|
|
441
883
|
for (const toolUseId of resultToolUseIds) {
|
|
442
884
|
const toolUse = toolUses[toolUseId];
|
|
443
885
|
if (toolUse?.name !== "SendMessage")
|
|
444
886
|
continue;
|
|
445
887
|
const text = promptText(toolUse.input?.message);
|
|
446
888
|
if (text)
|
|
447
|
-
return text;
|
|
889
|
+
return { toolUseId, text };
|
|
890
|
+
withoutText ??= toolUseId;
|
|
448
891
|
}
|
|
449
892
|
if (resultToolUseIds.length > 0)
|
|
450
|
-
return undefined;
|
|
451
|
-
for (const toolUse of Object.
|
|
893
|
+
return withoutText ? { toolUseId: withoutText } : undefined;
|
|
894
|
+
for (const [toolUseId, toolUse] of Object.entries(toolUses).reverse()) {
|
|
452
895
|
if (toolUse?.name !== "SendMessage")
|
|
453
896
|
continue;
|
|
454
897
|
const input = toolUse.input;
|
|
455
|
-
if (input?.to
|
|
456
|
-
|
|
898
|
+
if (input?.to !== agentId)
|
|
899
|
+
continue;
|
|
900
|
+
const text = promptText(input.message);
|
|
901
|
+
return text ? { toolUseId, text } : { toolUseId };
|
|
457
902
|
}
|
|
458
903
|
return undefined;
|
|
459
904
|
}
|
|
@@ -596,6 +1041,10 @@ function applySubagentIdentity(child, identity) {
|
|
|
596
1041
|
child.task = subagentDescription(identity.prompt, identity.description);
|
|
597
1042
|
}
|
|
598
1043
|
child.prompt ??= identity.prompt;
|
|
1044
|
+
const labels = rfdLabels(identity.name, identity.subagentType ?? child.role, identity.description);
|
|
1045
|
+
child.title = labels.title ?? child.title;
|
|
1046
|
+
child.role = labels.role ?? child.role;
|
|
1047
|
+
child.description = labels.description ?? child.description;
|
|
599
1048
|
}
|
|
600
1049
|
/** The prompt text unchanged, or `undefined` when it is not a non-blank string. */
|
|
601
1050
|
function promptText(value) {
|