@animalabs/connectome-host 0.3.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/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +32 -0
|
@@ -0,0 +1,2074 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubagentModule — spawn and fork ephemeral subagents.
|
|
3
|
+
*
|
|
4
|
+
* Tools:
|
|
5
|
+
* subagent--spawn — Fresh agent with system prompt + task, no inherited context
|
|
6
|
+
* subagent--fork — Agent inheriting parent's compiled context
|
|
7
|
+
* subagent--hud — Toggle fleet status HUD overlay
|
|
8
|
+
*
|
|
9
|
+
* By default, spawn/fork are async: they return immediately and deliver
|
|
10
|
+
* results as user messages + inference-request events. Pass `sync: true`
|
|
11
|
+
* to block until completion.
|
|
12
|
+
*
|
|
13
|
+
* Sync tasks are detachable: user can push them to background mid-flight
|
|
14
|
+
* via Ctrl+B in TUI, or they auto-detach after `timeoutMs` if specified.
|
|
15
|
+
* Both spawn and fork accept `timeoutMs` for per-task execution deadlines.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
Module,
|
|
20
|
+
ModuleContext,
|
|
21
|
+
ProcessState,
|
|
22
|
+
ProcessEvent,
|
|
23
|
+
EventResponse,
|
|
24
|
+
ToolDefinition,
|
|
25
|
+
ToolCall,
|
|
26
|
+
ToolResult,
|
|
27
|
+
TraceEvent,
|
|
28
|
+
ContextManager,
|
|
29
|
+
} from '@animalabs/agent-framework';
|
|
30
|
+
import type { AgentFramework } from '@animalabs/agent-framework';
|
|
31
|
+
import { KnowledgeStrategy } from '@animalabs/agent-framework';
|
|
32
|
+
import { isToolResultContent, isToolUseContent } from '@animalabs/membrane';
|
|
33
|
+
import type { ContentBlock } from '@animalabs/membrane';
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Types
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export interface SubagentModuleConfig {
|
|
40
|
+
/** Maximum fork/spawn depth (default: 3) */
|
|
41
|
+
maxDepth?: number;
|
|
42
|
+
/** Current depth (incremented for child subagent modules) */
|
|
43
|
+
currentDepth?: number;
|
|
44
|
+
/** Default model for subagents */
|
|
45
|
+
defaultModel?: string;
|
|
46
|
+
/** Default max tokens per subagent inference */
|
|
47
|
+
defaultMaxTokens?: number;
|
|
48
|
+
/** Which parent agent this module serves (for fork context access) */
|
|
49
|
+
parentAgentName?: string;
|
|
50
|
+
/** Max concurrent subagent executions (default: 3) */
|
|
51
|
+
maxConcurrent?: number;
|
|
52
|
+
/** Max prompt tokens before failing fast (default: 190000) */
|
|
53
|
+
maxPromptTokens?: number;
|
|
54
|
+
/** Max execution time per subagent in ms (default: 600000 = 10 min) */
|
|
55
|
+
maxExecutionMs?: number;
|
|
56
|
+
/** Max restart attempts on transient errors (default: 2) */
|
|
57
|
+
maxRetries?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SubagentResult {
|
|
61
|
+
summary: string;
|
|
62
|
+
findings: string[];
|
|
63
|
+
issues: string[];
|
|
64
|
+
toolCallsCount: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface SpawnInput {
|
|
68
|
+
name: string;
|
|
69
|
+
systemPrompt: string;
|
|
70
|
+
task: string;
|
|
71
|
+
model?: string;
|
|
72
|
+
maxTokens?: number;
|
|
73
|
+
tools?: string[];
|
|
74
|
+
sync?: boolean;
|
|
75
|
+
timeoutMs?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface ForkInput {
|
|
79
|
+
name: string;
|
|
80
|
+
task: string;
|
|
81
|
+
systemPrompt?: string;
|
|
82
|
+
model?: string;
|
|
83
|
+
maxTokens?: number;
|
|
84
|
+
sync?: boolean;
|
|
85
|
+
timeoutMs?: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Handle for an async (fire-and-forget) subagent. */
|
|
89
|
+
interface AsyncSubagentHandle {
|
|
90
|
+
name: string;
|
|
91
|
+
type: 'spawn' | 'fork';
|
|
92
|
+
promise: Promise<SubagentResult>;
|
|
93
|
+
parentAgentName: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Handle for a sync subagent that can be detached mid-flight.
|
|
98
|
+
* When detached, the blocking tool call resolves immediately and
|
|
99
|
+
* the subagent continues running, delivering results async.
|
|
100
|
+
*/
|
|
101
|
+
interface DetachableHandle {
|
|
102
|
+
name: string;
|
|
103
|
+
type: 'spawn' | 'fork';
|
|
104
|
+
promise: Promise<SubagentResult>;
|
|
105
|
+
parentAgentName: string;
|
|
106
|
+
detach: () => void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Non-retryable termination of a subagent. All abnormal-but-expected exits
|
|
111
|
+
* (user cancel, zombie reclaim, depth limit, etc.) use this so the catch
|
|
112
|
+
* path can distinguish "killed" from "transient network error".
|
|
113
|
+
*/
|
|
114
|
+
export class SubagentTerminated extends Error {
|
|
115
|
+
constructor(
|
|
116
|
+
public readonly reason: 'cancelled' | 'zombie' | 'killed',
|
|
117
|
+
public readonly partialOutput: string,
|
|
118
|
+
message?: string,
|
|
119
|
+
) {
|
|
120
|
+
super(message ?? `Subagent terminated: ${reason}`);
|
|
121
|
+
this.name = 'SubagentTerminated';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Persisted subagent state (stored in Chronicle module state).
|
|
126
|
+
* 'cancelled' = terminal-but-benign (user cancel, zombie reclaim, supersession).
|
|
127
|
+
* Postmortem 2026-05-28 P1 #4: separated from 'failed' so the TUI subagent
|
|
128
|
+
* list and the web admin tree agree on terminal cause. */
|
|
129
|
+
interface PersistedSubagent {
|
|
130
|
+
name: string;
|
|
131
|
+
type: 'spawn' | 'fork';
|
|
132
|
+
task: string;
|
|
133
|
+
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
|
134
|
+
startedAt: number;
|
|
135
|
+
/**
|
|
136
|
+
* Last time we saw a trace event addressed to this subagent (token, tool
|
|
137
|
+
* call, completion, etc.). Used as the staleness metric for zombie
|
|
138
|
+
* detection: `startedAt` alone can't distinguish a slow-but-progressing
|
|
139
|
+
* subagent from one that has been silently stuck for hours. Bumped on
|
|
140
|
+
* every inference-lifecycle event in the trace listener; persisted so
|
|
141
|
+
* it survives branch ops and session restores.
|
|
142
|
+
*/
|
|
143
|
+
lastActivityAt: number;
|
|
144
|
+
completedAt?: number;
|
|
145
|
+
toolCallsCount: number;
|
|
146
|
+
findingsCount: number;
|
|
147
|
+
statusMessage?: string;
|
|
148
|
+
parent?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Observable state of an active subagent, for TUI display. See
|
|
152
|
+
* PersistedSubagent.status — 'cancelled' is the same dedicated benign-
|
|
153
|
+
* terminal state introduced for the postmortem 2026-05-28 P1 #4 fix. */
|
|
154
|
+
export interface ActiveSubagent {
|
|
155
|
+
name: string;
|
|
156
|
+
type: 'spawn' | 'fork';
|
|
157
|
+
task: string;
|
|
158
|
+
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
|
159
|
+
startedAt: number;
|
|
160
|
+
/** Last addressed trace event timestamp. See PersistedSubagent.lastActivityAt. */
|
|
161
|
+
lastActivityAt: number;
|
|
162
|
+
completedAt?: number;
|
|
163
|
+
statusMessage?: string;
|
|
164
|
+
toolCallsCount: number;
|
|
165
|
+
findingsCount: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Live state captured for peek observability. */
|
|
169
|
+
interface LiveSubagentState {
|
|
170
|
+
frameworkAgentName: string;
|
|
171
|
+
displayName: string;
|
|
172
|
+
systemPrompt: string;
|
|
173
|
+
contextManager: ContextManager;
|
|
174
|
+
currentStream: string;
|
|
175
|
+
pendingToolCalls: Array<{ name: string; input?: unknown }>;
|
|
176
|
+
/** Track callIds from tool_calls_yielded so we can route tool:* events back. */
|
|
177
|
+
activeCallIds: Set<string>;
|
|
178
|
+
/** When set, an inference request has been dispatched but no token has
|
|
179
|
+
* arrived yet — i.e., the agent is provably alive waiting on the LLM
|
|
180
|
+
* provider. Postmortem 2026-05-28 F3: between `inference:started` /
|
|
181
|
+
* `inference:stream_resumed` and the first `inference:tokens`, both
|
|
182
|
+
* `currentStream` and `pendingToolCalls` are empty even though the
|
|
183
|
+
* request is on the wire. The reaper / peek predicates must treat this
|
|
184
|
+
* as a protected state, otherwise slow rounds (Opus on 100–165K-token
|
|
185
|
+
* contexts routinely exceed 30s TTFT) get reaped mid-request. Cleared
|
|
186
|
+
* on first token, completion, failure, or new tool yield. */
|
|
187
|
+
requestInFlightSince?: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Streaming event pushed to peek subscribers. */
|
|
191
|
+
export type SubagentStreamEvent =
|
|
192
|
+
| { type: 'inference:started' }
|
|
193
|
+
| { type: 'tokens'; content: string }
|
|
194
|
+
| { type: 'tool_calls'; calls: Array<{ name: string; input?: unknown }> }
|
|
195
|
+
| { type: 'tool:started'; tool: string; input?: unknown }
|
|
196
|
+
| { type: 'tool:completed'; tool: string; durationMs: number }
|
|
197
|
+
| { type: 'tool:failed'; tool: string; error: string }
|
|
198
|
+
| { type: 'inference:completed' }
|
|
199
|
+
| { type: 'inference:failed'; error: string }
|
|
200
|
+
| { type: 'stream_resumed' }
|
|
201
|
+
| { type: 'done'; summary: string; lastInputTokens?: number };
|
|
202
|
+
|
|
203
|
+
export type SubagentStreamCallback = (event: SubagentStreamEvent) => void;
|
|
204
|
+
|
|
205
|
+
/** Snapshot returned by peek(). */
|
|
206
|
+
export interface SubagentPeekSnapshot {
|
|
207
|
+
name: string;
|
|
208
|
+
type: 'spawn' | 'fork';
|
|
209
|
+
task: string;
|
|
210
|
+
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
|
211
|
+
startedAt: number;
|
|
212
|
+
elapsedMs: number;
|
|
213
|
+
messageCount: number;
|
|
214
|
+
lastMessageSnippet: string;
|
|
215
|
+
currentStream: string;
|
|
216
|
+
pendingToolCalls: Array<{ name: string; input?: unknown }>;
|
|
217
|
+
toolCallsCount: number;
|
|
218
|
+
/** True if the subagent appears stalled: running status, no active stream, elapsed > threshold. */
|
|
219
|
+
isZombie: boolean;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Fork materialisation
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Build the tool_result text the fork stream sees in place of the generic
|
|
228
|
+
* "Subagent X forked. Running in background." that the parent stream sees.
|
|
229
|
+
*
|
|
230
|
+
* The wording frames the fork as a parallel continuation of the same self
|
|
231
|
+
* rather than a separate agent — both streams inherit everything, the fork
|
|
232
|
+
* stream's job is to set aside the broader agenda and pursue one intention.
|
|
233
|
+
*
|
|
234
|
+
* This addresses fork-disorientation by naming the dual-stream situation
|
|
235
|
+
* explicitly and salient-placing it as the matching tool_result of the
|
|
236
|
+
* fork tool_use the model just emitted.
|
|
237
|
+
*/
|
|
238
|
+
export function buildIntentionFramedForkResult(
|
|
239
|
+
name: string,
|
|
240
|
+
task: string,
|
|
241
|
+
depth: number,
|
|
242
|
+
maxDepth: number,
|
|
243
|
+
): string {
|
|
244
|
+
const depthLine = depth < maxDepth
|
|
245
|
+
? `You are at depth ${depth} of ${maxDepth} (${maxDepth - depth} sub-fork levels remaining).`
|
|
246
|
+
: `You are at depth ${depth} of ${maxDepth} — at max depth, you cannot sub-fork.`;
|
|
247
|
+
return (
|
|
248
|
+
`Two parallel streams of you continue from this point, both inheriting everything you've done so far. ` +
|
|
249
|
+
`An instance of you still runs as the parent stream, carrying on the broader agenda; ` +
|
|
250
|
+
`the self reading this is the fork — set aside that broader agenda and focus exclusively on the intention ` +
|
|
251
|
+
`you set for this stream: ${task}\n\n` +
|
|
252
|
+
`${depthLine}\n\n` +
|
|
253
|
+
`When this intention is complete, return your findings via subagent--return so the parent stream can integrate them.`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Structural subset of context-manager's Message — accepting this wider
|
|
258
|
+
* shape lets the helper be unit-tested without importing the framework. */
|
|
259
|
+
interface MinimalMessage {
|
|
260
|
+
participant: string;
|
|
261
|
+
content: ContentBlock[];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Transform parent's compiled context into the fork stream's inherited view:
|
|
266
|
+
* 1. find the assistant turn containing the fork tool_use with id === callToolUseId
|
|
267
|
+
* 2. strip sibling subagent--fork tool_use blocks from that assistant turn
|
|
268
|
+
* 3. find (or synthesise) the matching tool_result user turn; strip sibling
|
|
269
|
+
* fork tool_results from it; rewrite the matching one with intention framing
|
|
270
|
+
* 4. drop every message after the matching tool_result (post-fork tail —
|
|
271
|
+
* peek calls, zombie returns, parent narrative — is the other major
|
|
272
|
+
* load-bearing source of parent-coherent evidence)
|
|
273
|
+
*
|
|
274
|
+
* Returns null if the matching tool_use cannot be located (e.g. compressed
|
|
275
|
+
* away by the parent's strategy). Callers should fall back to wholesale
|
|
276
|
+
* copy + synthetic intention-framed append in that case.
|
|
277
|
+
*/
|
|
278
|
+
export function materialiseStructuralFork(
|
|
279
|
+
compiled: ReadonlyArray<MinimalMessage>,
|
|
280
|
+
callToolUseId: string,
|
|
281
|
+
forkName: string,
|
|
282
|
+
forkTask: string,
|
|
283
|
+
depth: number,
|
|
284
|
+
maxDepth: number,
|
|
285
|
+
): MinimalMessage[] | null {
|
|
286
|
+
// 1. Locate the assistant turn whose content includes the fork tool_use we're materialising.
|
|
287
|
+
let forkAssistantIdx = -1;
|
|
288
|
+
for (let i = 0; i < compiled.length; i++) {
|
|
289
|
+
const content = compiled[i].content;
|
|
290
|
+
if (!Array.isArray(content)) continue;
|
|
291
|
+
const hit = content.some(b =>
|
|
292
|
+
isToolUseContent(b) && b.id === callToolUseId && b.name === 'subagent--fork',
|
|
293
|
+
);
|
|
294
|
+
if (hit) { forkAssistantIdx = i; break; }
|
|
295
|
+
}
|
|
296
|
+
if (forkAssistantIdx < 0) return null;
|
|
297
|
+
|
|
298
|
+
const forkAssistantMsg = compiled[forkAssistantIdx];
|
|
299
|
+
const siblingForkIds = new Set<string>();
|
|
300
|
+
for (const b of forkAssistantMsg.content) {
|
|
301
|
+
if (!isToolUseContent(b)) continue;
|
|
302
|
+
if (b.name === 'subagent--fork' && b.id !== callToolUseId) {
|
|
303
|
+
siblingForkIds.add(b.id);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 2. Find the next user-side message holding the matching tool_result (if any).
|
|
308
|
+
// Stop searching at the next assistant turn — the matching tool_result must
|
|
309
|
+
// be in the immediately following tool-result turn or it's not there yet.
|
|
310
|
+
let matchingResultIdx = -1;
|
|
311
|
+
for (let i = forkAssistantIdx + 1; i < compiled.length; i++) {
|
|
312
|
+
const m = compiled[i];
|
|
313
|
+
if (!Array.isArray(m.content)) continue;
|
|
314
|
+
const hit = m.content.some(b => isToolResultContent(b) && b.toolUseId === callToolUseId);
|
|
315
|
+
if (hit) { matchingResultIdx = i; break; }
|
|
316
|
+
// If this message contains any tool_use, it's a new assistant turn — bail.
|
|
317
|
+
if (m.content.some(b => isToolUseContent(b))) break;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const out: MinimalMessage[] = [];
|
|
321
|
+
|
|
322
|
+
// Pre-fork history verbatim. Blocks are shared by reference everywhere in
|
|
323
|
+
// the framework's context flow — no cloning here either.
|
|
324
|
+
for (let i = 0; i < forkAssistantIdx; i++) {
|
|
325
|
+
out.push({ participant: compiled[i].participant, content: compiled[i].content });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Fork assistant turn with sibling fork tool_use blocks stripped. The
|
|
329
|
+
// matching tool_use is excluded from siblingForkIds by construction, so
|
|
330
|
+
// this filter always keeps at least one block.
|
|
331
|
+
const trimmedAssistantContent = forkAssistantMsg.content.filter(b => {
|
|
332
|
+
if (!isToolUseContent(b)) return true;
|
|
333
|
+
return !(b.name === 'subagent--fork' && siblingForkIds.has(b.id));
|
|
334
|
+
});
|
|
335
|
+
out.push({ participant: forkAssistantMsg.participant, content: trimmedAssistantContent });
|
|
336
|
+
|
|
337
|
+
// Matching tool_result user turn — rewritten with intention framing; siblings stripped.
|
|
338
|
+
const intentionFramed = buildIntentionFramedForkResult(forkName, forkTask, depth, maxDepth);
|
|
339
|
+
if (matchingResultIdx >= 0) {
|
|
340
|
+
const matchingMsg = compiled[matchingResultIdx];
|
|
341
|
+
const trimmedResultContent: ContentBlock[] = [];
|
|
342
|
+
for (const b of matchingMsg.content) {
|
|
343
|
+
if (isToolResultContent(b)) {
|
|
344
|
+
if (siblingForkIds.has(b.toolUseId)) continue;
|
|
345
|
+
if (b.toolUseId === callToolUseId) {
|
|
346
|
+
trimmedResultContent.push({
|
|
347
|
+
type: 'tool_result',
|
|
348
|
+
toolUseId: callToolUseId,
|
|
349
|
+
content: intentionFramed,
|
|
350
|
+
});
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
trimmedResultContent.push(b);
|
|
355
|
+
}
|
|
356
|
+
if (trimmedResultContent.length === 0) {
|
|
357
|
+
// All siblings, nothing else — synthesise the matching result alone.
|
|
358
|
+
trimmedResultContent.push({
|
|
359
|
+
type: 'tool_result',
|
|
360
|
+
toolUseId: callToolUseId,
|
|
361
|
+
content: intentionFramed,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
out.push({ participant: matchingMsg.participant, content: trimmedResultContent });
|
|
365
|
+
} else {
|
|
366
|
+
// Parent's tool_result for this fork isn't in compiled context yet — synthesise it.
|
|
367
|
+
out.push({
|
|
368
|
+
participant: 'user',
|
|
369
|
+
content: [{
|
|
370
|
+
type: 'tool_result',
|
|
371
|
+
toolUseId: callToolUseId,
|
|
372
|
+
content: intentionFramed,
|
|
373
|
+
}],
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Post-fork tail intentionally dropped.
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// Module
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
export class SubagentModule implements Module {
|
|
386
|
+
readonly name = 'subagent';
|
|
387
|
+
|
|
388
|
+
private ctx: ModuleContext | null = null;
|
|
389
|
+
private config: SubagentModuleConfig;
|
|
390
|
+
private framework: AgentFramework | null = null;
|
|
391
|
+
private maxDepth: number;
|
|
392
|
+
private currentDepth: number;
|
|
393
|
+
private asyncHandles = new Map<string, AsyncSubagentHandle>();
|
|
394
|
+
private detachableHandles = new Map<string, DetachableHandle>();
|
|
395
|
+
|
|
396
|
+
// Concurrency control — adaptive rate-limit-aware semaphore
|
|
397
|
+
private configuredMaxConcurrent: number; // User's ceiling
|
|
398
|
+
private effectiveConcurrent: number; // Current effective limit (may be reduced)
|
|
399
|
+
private activeConcurrent = 0;
|
|
400
|
+
private waitQueue: Array<() => void> = [];
|
|
401
|
+
private consecutiveSuccesses = 0;
|
|
402
|
+
private lastRateLimitAt = 0;
|
|
403
|
+
private rateLimitCooldownMs = 30_000; // Delay after rate limit before releasing next slot
|
|
404
|
+
|
|
405
|
+
// Periodic zombie reaper. The pre-existing reaper only ran on-demand
|
|
406
|
+
// inside acquireSlot, which meant zombies persisted indefinitely when
|
|
407
|
+
// no new spawns happened — production traces showed a stale subagent
|
|
408
|
+
// holding a slot for 7 days before a fresh spawn finally tripped the
|
|
409
|
+
// demand-driven reap. This interval gives us a steady heartbeat reap
|
|
410
|
+
// independent of spawn activity.
|
|
411
|
+
private reaperTimer: ReturnType<typeof setInterval> | null = null;
|
|
412
|
+
// Postmortem 2026-05-28 F3: bumped from 30_000ms. With the in-flight guard
|
|
413
|
+
// closing the "request on wire" false-positive window, the threshold is
|
|
414
|
+
// less load-bearing — but 30s was still tight enough that JIT pauses, GC,
|
|
415
|
+
// or transient hiccups under heavy fleet load could trip the reaper on a
|
|
416
|
+
// genuinely-progressing-but-pausing agent. 60s sweep cadence is also
|
|
417
|
+
// friendlier when the fleet is already throttled by 429s (reaping under
|
|
418
|
+
// throttling is counterproductive — the slot isn't stuck, it's queued).
|
|
419
|
+
private static readonly REAPER_INTERVAL_MS = 60_000;
|
|
420
|
+
|
|
421
|
+
// Prompt size guard
|
|
422
|
+
private maxPromptTokens: number;
|
|
423
|
+
|
|
424
|
+
// Per-subagent execution deadline
|
|
425
|
+
private maxExecutionMs: number;
|
|
426
|
+
|
|
427
|
+
// Retry on transient errors
|
|
428
|
+
private maxRetries: number;
|
|
429
|
+
|
|
430
|
+
/** Observable registry of active/recent subagents for TUI display. */
|
|
431
|
+
readonly activeSubagents = new Map<string, ActiveSubagent>();
|
|
432
|
+
|
|
433
|
+
/** Parent agent name for each subagent (for fleet tree reconstruction). */
|
|
434
|
+
readonly parentMap = new Map<string, string>();
|
|
435
|
+
|
|
436
|
+
// Stashed results from subagent--return tool calls, keyed by framework agent name
|
|
437
|
+
private returnedResults = new Map<string, string>();
|
|
438
|
+
|
|
439
|
+
// Live state for peek observability
|
|
440
|
+
private liveSubagents = new Map<string, LiveSubagentState>(); // keyed by displayName
|
|
441
|
+
private frameworkNameIndex = new Map<string, string>(); // frameworkAgentName → displayName
|
|
442
|
+
private callIdIndex = new Map<string, string>(); // toolCallId → displayName
|
|
443
|
+
private streamSubscribers = new Map<string, Set<SubagentStreamCallback>>(); // displayName → callbacks
|
|
444
|
+
private lastInputTokens = new Map<string, number>(); // displayName → last known input token count
|
|
445
|
+
private cancellationHandles = new Map<string, { reject: (err: Error) => void }>(); // displayName → cancel
|
|
446
|
+
private agentDepths = new Map<string, number>(); // framework agent name → fork depth
|
|
447
|
+
|
|
448
|
+
constructor(config: SubagentModuleConfig = {}) {
|
|
449
|
+
this.config = config;
|
|
450
|
+
this.maxDepth = config.maxDepth ?? 3;
|
|
451
|
+
this.currentDepth = config.currentDepth ?? 0;
|
|
452
|
+
this.configuredMaxConcurrent = config.maxConcurrent ?? 5;
|
|
453
|
+
this.effectiveConcurrent = this.configuredMaxConcurrent;
|
|
454
|
+
this.maxPromptTokens = config.maxPromptTokens ?? 190_000;
|
|
455
|
+
this.maxExecutionMs = config.maxExecutionMs ?? 600_000;
|
|
456
|
+
this.maxRetries = config.maxRetries ?? 2;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Set the framework reference. Must be called after framework creation. */
|
|
460
|
+
setFramework(framework: AgentFramework): void {
|
|
461
|
+
this.framework = framework;
|
|
462
|
+
|
|
463
|
+
// Subscribe to traces for peek observability + streaming fanout
|
|
464
|
+
framework.onTrace((event: TraceEvent) => {
|
|
465
|
+
// Events with agentName: inference lifecycle
|
|
466
|
+
const agentName = 'agentName' in event ? (event as { agentName: string }).agentName : null;
|
|
467
|
+
|
|
468
|
+
if (agentName) {
|
|
469
|
+
const displayName = this.frameworkNameIndex.get(agentName);
|
|
470
|
+
if (!displayName) return;
|
|
471
|
+
const live = this.liveSubagents.get(displayName);
|
|
472
|
+
if (!live) return;
|
|
473
|
+
|
|
474
|
+
// Bump lastActivityAt on every addressed event. This is the staleness
|
|
475
|
+
// metric the periodic reaper consults — without it, the only signal
|
|
476
|
+
// is `startedAt`, which can't distinguish a 10-hour-but-progressing
|
|
477
|
+
// subagent from one that has been silently stuck. Look up the
|
|
478
|
+
// ActiveSubagent record by entry-key (a separate index, since
|
|
479
|
+
// displayName isn't the activeSubagents key directly).
|
|
480
|
+
for (const sa of this.activeSubagents.values()) {
|
|
481
|
+
if (sa.name === displayName) {
|
|
482
|
+
sa.lastActivityAt = Date.now();
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// inference:usage is emitted at runtime but not in the TraceEvent union — handle it first
|
|
488
|
+
if ((event as { type: string }).type === 'inference:usage') {
|
|
489
|
+
const roundUsage = (event as { tokenUsage?: { input?: number } }).tokenUsage;
|
|
490
|
+
if (roundUsage?.input) this.lastInputTokens.set(displayName, roundUsage.input);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Postmortem 2026-05-28 F3: track the request-in-flight window.
|
|
495
|
+
// `inference:started` and `inference:stream_resumed` dispatch a new
|
|
496
|
+
// LLM request; the first `inference:tokens`, `inference:completed`,
|
|
497
|
+
// `inference:failed`, or `inference:tool_calls_yielded` signals the
|
|
498
|
+
// provider has responded. Between those, the reaper / peek predicates
|
|
499
|
+
// treat `requestInFlightSince` as proof the agent isn't stuck.
|
|
500
|
+
switch (event.type) {
|
|
501
|
+
case 'inference:started':
|
|
502
|
+
live.currentStream = '';
|
|
503
|
+
live.pendingToolCalls = [];
|
|
504
|
+
live.activeCallIds.clear();
|
|
505
|
+
live.requestInFlightSince = Date.now();
|
|
506
|
+
this.emit(displayName, { type: 'inference:started' });
|
|
507
|
+
break;
|
|
508
|
+
case 'inference:tokens': {
|
|
509
|
+
const content = (event as { content?: string }).content ?? '';
|
|
510
|
+
live.currentStream += content;
|
|
511
|
+
live.requestInFlightSince = undefined;
|
|
512
|
+
this.emit(displayName, { type: 'tokens', content });
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
case 'inference:tool_calls_yielded': {
|
|
516
|
+
const calls = (event as { calls?: Array<{ id: string; name: string; input?: unknown }> }).calls ?? [];
|
|
517
|
+
live.pendingToolCalls = calls.map(c => ({ name: c.name, input: c.input }));
|
|
518
|
+
live.currentStream = '';
|
|
519
|
+
live.requestInFlightSince = undefined;
|
|
520
|
+
// Index callIds so we can route tool:* events back
|
|
521
|
+
for (const c of calls) {
|
|
522
|
+
live.activeCallIds.add(c.id);
|
|
523
|
+
this.callIdIndex.set(c.id, displayName);
|
|
524
|
+
}
|
|
525
|
+
this.emit(displayName, { type: 'tool_calls', calls: calls.map(c => ({ name: c.name, input: c.input })) });
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
case 'inference:stream_resumed':
|
|
529
|
+
live.currentStream = '';
|
|
530
|
+
live.pendingToolCalls = [];
|
|
531
|
+
live.requestInFlightSince = Date.now();
|
|
532
|
+
this.emit(displayName, { type: 'stream_resumed' });
|
|
533
|
+
break;
|
|
534
|
+
case 'inference:completed': {
|
|
535
|
+
const usage = (event as { tokenUsage?: { input?: number } }).tokenUsage;
|
|
536
|
+
if (usage?.input) this.lastInputTokens.set(displayName, usage.input);
|
|
537
|
+
live.requestInFlightSince = undefined;
|
|
538
|
+
this.emit(displayName, { type: 'inference:completed' });
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
case 'inference:failed': {
|
|
542
|
+
const error = (event as { error?: string }).error ?? 'Unknown error';
|
|
543
|
+
live.requestInFlightSince = undefined;
|
|
544
|
+
this.emit(displayName, { type: 'inference:failed', error });
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Events with callId: tool lifecycle (no agentName)
|
|
552
|
+
const callId = 'callId' in event ? (event as { callId: string }).callId : null;
|
|
553
|
+
if (callId) {
|
|
554
|
+
const displayName = this.callIdIndex.get(callId);
|
|
555
|
+
if (!displayName) return;
|
|
556
|
+
|
|
557
|
+
switch (event.type) {
|
|
558
|
+
case 'tool:started': {
|
|
559
|
+
const e = event as { tool: string; input?: unknown };
|
|
560
|
+
this.emit(displayName, { type: 'tool:started', tool: e.tool, input: e.input });
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
case 'tool:completed': {
|
|
564
|
+
const e = event as { tool: string; durationMs: number };
|
|
565
|
+
this.callIdIndex.delete(callId);
|
|
566
|
+
this.emit(displayName, { type: 'tool:completed', tool: e.tool, durationMs: e.durationMs });
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
case 'tool:failed': {
|
|
570
|
+
const e = event as { tool: string; error: string };
|
|
571
|
+
this.callIdIndex.delete(callId);
|
|
572
|
+
this.emit(displayName, { type: 'tool:failed', tool: e.tool, error: e.error });
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private getFramework(): AgentFramework {
|
|
581
|
+
if (!this.framework) throw new Error('SubagentModule: framework not set. Call setFramework() after creating the framework.');
|
|
582
|
+
return this.framework;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async start(ctx: ModuleContext): Promise<void> {
|
|
586
|
+
this.ctx = ctx;
|
|
587
|
+
// Restore in-memory state from Chronicle (for session restore / branch switch)
|
|
588
|
+
this.restoreFromStore();
|
|
589
|
+
|
|
590
|
+
// Periodic zombie reap independent of acquireSlot demand. See reaperTimer
|
|
591
|
+
// comment above. Wrapped in try/catch — a reap-loop bug must not crash
|
|
592
|
+
// the module.
|
|
593
|
+
this.reaperTimer = setInterval(() => {
|
|
594
|
+
try {
|
|
595
|
+
const reclaimed = this.reclaimZombieSlots();
|
|
596
|
+
if (reclaimed > 0) {
|
|
597
|
+
// Persist updated statuses so chronicle reflects the reap.
|
|
598
|
+
this.persistState();
|
|
599
|
+
}
|
|
600
|
+
} catch (error) {
|
|
601
|
+
console.error('[subagent] periodic reaper error:', error);
|
|
602
|
+
}
|
|
603
|
+
}, SubagentModule.REAPER_INTERVAL_MS);
|
|
604
|
+
// Don't keep the event loop alive just for the reaper.
|
|
605
|
+
if (typeof (this.reaperTimer as { unref?: () => void }).unref === 'function') {
|
|
606
|
+
(this.reaperTimer as { unref: () => void }).unref();
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async stop(): Promise<void> {
|
|
611
|
+
if (this.reaperTimer) {
|
|
612
|
+
clearInterval(this.reaperTimer);
|
|
613
|
+
this.reaperTimer = null;
|
|
614
|
+
}
|
|
615
|
+
this.ctx = null;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Persist subagent registry to Chronicle module state.
|
|
620
|
+
* Called after each lifecycle transition so branch ops get correct fleet.
|
|
621
|
+
*/
|
|
622
|
+
private persistState(): void {
|
|
623
|
+
if (!this.ctx) return;
|
|
624
|
+
const agents: Record<string, PersistedSubagent> = {};
|
|
625
|
+
for (const [key, sa] of this.activeSubagents) {
|
|
626
|
+
agents[key] = {
|
|
627
|
+
name: sa.name,
|
|
628
|
+
type: sa.type,
|
|
629
|
+
task: sa.task,
|
|
630
|
+
status: sa.status,
|
|
631
|
+
startedAt: sa.startedAt,
|
|
632
|
+
lastActivityAt: sa.lastActivityAt,
|
|
633
|
+
completedAt: sa.completedAt,
|
|
634
|
+
toolCallsCount: sa.toolCallsCount,
|
|
635
|
+
findingsCount: sa.findingsCount,
|
|
636
|
+
statusMessage: sa.statusMessage,
|
|
637
|
+
parent: this.parentMap.get(sa.name),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
this.ctx.setState({ agents });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Restore activeSubagents + parentMap from Chronicle module state.
|
|
645
|
+
* Marks any 'running' entries as 'cancelled' since the actual processes are gone.
|
|
646
|
+
* Postmortem 2026-05-28 P1 #4: use 'cancelled' (not 'completed') so the
|
|
647
|
+
* operator can tell host-interruption from genuine completion in the TUI.
|
|
648
|
+
*/
|
|
649
|
+
restoreFromStore(): void {
|
|
650
|
+
if (!this.ctx) return;
|
|
651
|
+
const persisted = this.ctx.getState<{ agents?: Record<string, PersistedSubagent> }>();
|
|
652
|
+
if (!persisted?.agents) return;
|
|
653
|
+
|
|
654
|
+
this.activeSubagents.clear();
|
|
655
|
+
this.parentMap.clear();
|
|
656
|
+
|
|
657
|
+
for (const [key, pa] of Object.entries(persisted.agents)) {
|
|
658
|
+
const sa: ActiveSubagent = {
|
|
659
|
+
name: pa.name,
|
|
660
|
+
type: pa.type,
|
|
661
|
+
task: pa.task,
|
|
662
|
+
status: pa.status === 'running' ? 'cancelled' : pa.status,
|
|
663
|
+
startedAt: pa.startedAt,
|
|
664
|
+
// Fallback for records from versions before lastActivityAt was added:
|
|
665
|
+
// treat them as if their last activity was their start time. Affects
|
|
666
|
+
// only the staleness heuristic, which will simply flag them as stale
|
|
667
|
+
// immediately on next reaper sweep — correct, because by definition
|
|
668
|
+
// the process owning them is gone.
|
|
669
|
+
lastActivityAt: pa.lastActivityAt ?? pa.startedAt,
|
|
670
|
+
completedAt: pa.completedAt ?? (pa.status === 'running' ? Date.now() : undefined),
|
|
671
|
+
toolCallsCount: pa.toolCallsCount,
|
|
672
|
+
findingsCount: pa.findingsCount,
|
|
673
|
+
statusMessage: pa.status === 'running' ? 'interrupted (branch/session switch)' : pa.statusMessage,
|
|
674
|
+
};
|
|
675
|
+
this.activeSubagents.set(key, sa);
|
|
676
|
+
if (pa.parent) {
|
|
677
|
+
this.parentMap.set(pa.name, pa.parent);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
getTools(): ToolDefinition[] {
|
|
683
|
+
return [
|
|
684
|
+
{
|
|
685
|
+
name: 'spawn',
|
|
686
|
+
description: 'Spawn a fresh subagent with a system prompt and task. Async by default — returns immediately and delivers results as a message. Pass sync:true to block until completion.',
|
|
687
|
+
inputSchema: {
|
|
688
|
+
type: 'object',
|
|
689
|
+
properties: {
|
|
690
|
+
name: { type: 'string', description: 'Short name for the subagent' },
|
|
691
|
+
systemPrompt: { type: 'string', description: 'System prompt for the subagent' },
|
|
692
|
+
task: { type: 'string', description: 'The task for the subagent to perform' },
|
|
693
|
+
model: { type: 'string', description: 'Model override (optional)' },
|
|
694
|
+
maxTokens: { type: 'number', description: 'Max output tokens per inference (optional). Defaults to the recipe-level subagent default, else the parent agent\'s maxTokens.' },
|
|
695
|
+
tools: {
|
|
696
|
+
type: 'array',
|
|
697
|
+
items: { type: 'string' },
|
|
698
|
+
description: 'Tool names the subagent can use (default: all). Note: subagent--return is always included automatically.',
|
|
699
|
+
},
|
|
700
|
+
sync: { type: 'boolean', description: 'If true, block until subagent completes (default: false)' },
|
|
701
|
+
timeoutMs: { type: 'number', description: 'Execution timeout in milliseconds. Sync tasks default to 600s (auto-detaches to background). Async tasks have no default timeout — only set this if you need a hard deadline.' },
|
|
702
|
+
},
|
|
703
|
+
required: ['name', 'systemPrompt', 'task'],
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
name: 'fork',
|
|
708
|
+
description:
|
|
709
|
+
"Fork the current conversation into two parallel streams that share all memory of what you've done so far. " +
|
|
710
|
+
"Both streams are continuations of the same self — one carries on with the broader agenda, the other focuses " +
|
|
711
|
+
"exclusively on a new intention (the `task`). The tool_result you receive identifies which stream you are. " +
|
|
712
|
+
"Async by default; pass sync:true to block until completion.",
|
|
713
|
+
inputSchema: {
|
|
714
|
+
type: 'object',
|
|
715
|
+
properties: {
|
|
716
|
+
name: { type: 'string', description: 'Short name for the forked stream' },
|
|
717
|
+
task: { type: 'string', description: 'The intention this fork stream should pursue' },
|
|
718
|
+
systemPrompt: { type: 'string', description: 'Override system prompt (optional, defaults to parent)' },
|
|
719
|
+
model: { type: 'string', description: 'Model override (optional)' },
|
|
720
|
+
maxTokens: { type: 'number', description: 'Max output tokens per inference (optional). Defaults to the recipe-level subagent default, else the parent agent\'s maxTokens.' },
|
|
721
|
+
sync: { type: 'boolean', description: 'If true, block until fork completes (default: false)' },
|
|
722
|
+
timeoutMs: { type: 'number', description: 'Execution timeout in milliseconds. Sync tasks default to 600s (auto-detaches to background). Async tasks have no default timeout — only set this if you need a hard deadline.' },
|
|
723
|
+
},
|
|
724
|
+
required: ['name', 'task'],
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
name: 'hud',
|
|
729
|
+
description: 'Toggle the subagent fleet HUD overlay. When enabled, a compact fleet status summary is injected before each inference.',
|
|
730
|
+
inputSchema: {
|
|
731
|
+
type: 'object',
|
|
732
|
+
properties: {
|
|
733
|
+
enabled: { type: 'boolean', description: 'Enable or disable the fleet HUD' },
|
|
734
|
+
},
|
|
735
|
+
required: ['enabled'],
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
{
|
|
739
|
+
name: 'concurrency',
|
|
740
|
+
description: 'View or adjust subagent concurrency. Omit maxConcurrent to just view status. Concurrency auto-adapts to rate limits (halves on 429, recovers after successes).',
|
|
741
|
+
inputSchema: {
|
|
742
|
+
type: 'object',
|
|
743
|
+
properties: {
|
|
744
|
+
maxConcurrent: { type: 'number', description: 'Set new concurrency ceiling (min 1)' },
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
name: 'peek',
|
|
750
|
+
description: 'Peek at a running subagent\'s live state: status, elapsed time, message count, last message snippet, current streaming output, and pending tool calls. Omit name to peek at all running subagents.',
|
|
751
|
+
inputSchema: {
|
|
752
|
+
type: 'object',
|
|
753
|
+
properties: {
|
|
754
|
+
name: { type: 'string', description: 'Subagent name to peek at (omit for all)' },
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
name: 'return',
|
|
760
|
+
description: 'Return results from a fork or spawn back to the parent agent. Call this when you have completed your task. Your result text will be delivered to the parent as the tool result of the fork/spawn call. This ends your execution.',
|
|
761
|
+
inputSchema: {
|
|
762
|
+
type: 'object',
|
|
763
|
+
properties: {
|
|
764
|
+
result: { type: 'string', description: 'Your findings, summary, or results to return to the parent' },
|
|
765
|
+
},
|
|
766
|
+
required: ['result'],
|
|
767
|
+
},
|
|
768
|
+
},
|
|
769
|
+
];
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
|
773
|
+
const caller = call.callerAgentName;
|
|
774
|
+
switch (call.name) {
|
|
775
|
+
case 'spawn':
|
|
776
|
+
return this.handleSpawn(call.input as SpawnInput, caller);
|
|
777
|
+
case 'fork':
|
|
778
|
+
return this.handleFork(call.input as ForkInput, caller, call.id);
|
|
779
|
+
case 'hud':
|
|
780
|
+
return this.handleHud(call.input as { enabled: boolean });
|
|
781
|
+
case 'concurrency':
|
|
782
|
+
return this.handleConcurrency(call.input as { maxConcurrent?: number });
|
|
783
|
+
case 'peek':
|
|
784
|
+
return this.handlePeek(call.input as { name?: string }, caller);
|
|
785
|
+
case 'return': {
|
|
786
|
+
// Stash the result keyed by the tool call ID. The completion path
|
|
787
|
+
// in runSpawn/runFork will pick it up via the callIdIndex → displayName.
|
|
788
|
+
const result = (call.input as { result: string }).result;
|
|
789
|
+
// Find which subagent is calling this via the callIdIndex
|
|
790
|
+
const callerName = this.callIdIndex.get(call.id);
|
|
791
|
+
if (callerName) {
|
|
792
|
+
this.returnedResults.set(callerName, result);
|
|
793
|
+
}
|
|
794
|
+
return { success: true, data: 'Result received.', endTurn: true };
|
|
795
|
+
}
|
|
796
|
+
default:
|
|
797
|
+
return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
|
|
802
|
+
// When an async subagent completes, deliverAsyncResult() pushes an
|
|
803
|
+
// inference-request event. Convert it into a proper requestInference
|
|
804
|
+
// response so the framework (and EventGate) can route it normally.
|
|
805
|
+
if (event.type === 'inference-request' && 'source' in event && event.source === 'subagent') {
|
|
806
|
+
return { requestInference: [(event as { agentName: string }).agentName] };
|
|
807
|
+
}
|
|
808
|
+
return {};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async gatherContext(agentName: string): Promise<import('@animalabs/context-manager').ContextInjection[]> {
|
|
812
|
+
if (!this.ctx) return [];
|
|
813
|
+
const persisted = this.ctx.getState<{ hudEnabled?: boolean }>() ?? {};
|
|
814
|
+
if (!persisted.hudEnabled) return [];
|
|
815
|
+
|
|
816
|
+
// Postmortem 2026-05-28 P3 #8: scope the Fleet Status HUD to the
|
|
817
|
+
// calling agent's descendants. Pre-fix the HUD injected the whole
|
|
818
|
+
// fleet roster every turn, which (a) encouraged peer-coordination
|
|
819
|
+
// anti-patterns and (b) amplified false zombie/failed flags across
|
|
820
|
+
// the orchestrator's context.
|
|
821
|
+
const allowed = this.getDescendantDisplayNames(agentName);
|
|
822
|
+
|
|
823
|
+
const lines: string[] = [];
|
|
824
|
+
for (const [, sa] of this.activeSubagents) {
|
|
825
|
+
if (!allowed.has(sa.name)) continue;
|
|
826
|
+
const elapsed = sa.completedAt
|
|
827
|
+
? Math.floor((sa.completedAt - sa.startedAt) / 1000)
|
|
828
|
+
: Math.floor((Date.now() - sa.startedAt) / 1000);
|
|
829
|
+
const parent = this.parentMap.get(sa.name) ?? 'agent';
|
|
830
|
+
const parentShort = parent.replace(/^(spawn|fork)-/, '').replace(/-d\d+-\d+$/, '').replace(/-retry\d+$/, '');
|
|
831
|
+
const task = sa.task.length > 50 ? sa.task.slice(0, 47) + '...' : sa.task;
|
|
832
|
+
lines.push(` ${sa.name} [${sa.type}] ${sa.status} ${elapsed}s ${sa.toolCallsCount}calls parent:${parentShort} "${task}"`);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Also show async handles still running, but only those owned by the
|
|
836
|
+
// caller (or its descendants — but async handles aren't parent-indexed
|
|
837
|
+
// separately, so conservatively skip them when scoping is in effect
|
|
838
|
+
// and the caller has no descendants to begin with).
|
|
839
|
+
for (const [name] of this.asyncHandles) {
|
|
840
|
+
if (!allowed.has(name)) continue;
|
|
841
|
+
if (!this.activeSubagents.has(name) && !this.activeSubagents.has(`spawn-${name}`)) {
|
|
842
|
+
lines.push(` ${name} [async] pending`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (lines.length === 0) return [];
|
|
847
|
+
|
|
848
|
+
return [{
|
|
849
|
+
namespace: 'subagent-fleet',
|
|
850
|
+
position: 'afterUser',
|
|
851
|
+
content: [{ type: 'text', text: `[Fleet Status]\n${lines.join('\n')}` }],
|
|
852
|
+
}];
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// =========================================================================
|
|
856
|
+
// Concurrency Control (adaptive, rate-limit-aware)
|
|
857
|
+
// =========================================================================
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Acquire a concurrency slot. Returns how long the caller waited (0 = immediate).
|
|
861
|
+
* Throws if the slot is not acquired within `slotTimeoutMs`.
|
|
862
|
+
*/
|
|
863
|
+
private async acquireSlot(slotTimeoutMs = 120_000): Promise<{ waitedMs: number }> {
|
|
864
|
+
if (this.activeConcurrent < this.effectiveConcurrent) {
|
|
865
|
+
this.activeConcurrent++;
|
|
866
|
+
return { waitedMs: 0 };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Before queueing, try to reclaim slots from zombie subagents
|
|
870
|
+
const reclaimedZombies = this.reclaimZombieSlots();
|
|
871
|
+
if (reclaimedZombies > 0 && this.activeConcurrent < this.effectiveConcurrent) {
|
|
872
|
+
this.activeConcurrent++;
|
|
873
|
+
return { waitedMs: 0 };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const startWait = Date.now();
|
|
877
|
+
return new Promise<{ waitedMs: number }>((resolve, reject) => {
|
|
878
|
+
const timer = setTimeout(() => {
|
|
879
|
+
// Remove ourselves from the wait queue
|
|
880
|
+
const idx = this.waitQueue.indexOf(onSlot);
|
|
881
|
+
if (idx >= 0) this.waitQueue.splice(idx, 1);
|
|
882
|
+
|
|
883
|
+
// Last-chance zombie reclamation before failing
|
|
884
|
+
const reclaimed = this.reclaimZombieSlots();
|
|
885
|
+
if (reclaimed > 0 && this.activeConcurrent < this.effectiveConcurrent) {
|
|
886
|
+
this.activeConcurrent++;
|
|
887
|
+
resolve({ waitedMs: Date.now() - startWait });
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Surface which subagents currently hold the slots, with silence
|
|
892
|
+
// and total runtime. Without this the caller only sees "5/5 in
|
|
893
|
+
// use" and has no signal for which to cancel. In production this
|
|
894
|
+
// lack of detail meant the parent agent couldn't self-rescue —
|
|
895
|
+
// it had to wait days for the demand-driven reaper to fire on
|
|
896
|
+
// its own.
|
|
897
|
+
const holders = this.describeSlotHolders();
|
|
898
|
+
const holderLines = holders.length > 0
|
|
899
|
+
? `\nSlots held by:\n${holders.map(h => ` - ${h}`).join('\n')}`
|
|
900
|
+
: '';
|
|
901
|
+
|
|
902
|
+
reject(new Error(
|
|
903
|
+
`Timed out waiting for a concurrency slot after ${slotTimeoutMs}ms ` +
|
|
904
|
+
`(${this.activeConcurrent}/${this.effectiveConcurrent} slots in use, ` +
|
|
905
|
+
`${this.waitQueue.length} still queued). ` +
|
|
906
|
+
`Limit parallel forks/spawns to ${this.effectiveConcurrent}.` +
|
|
907
|
+
holderLines
|
|
908
|
+
));
|
|
909
|
+
}, slotTimeoutMs);
|
|
910
|
+
|
|
911
|
+
const onSlot = () => {
|
|
912
|
+
clearTimeout(timer);
|
|
913
|
+
resolve({ waitedMs: Date.now() - startWait });
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
this.waitQueue.push(onSlot);
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Summarize subagents currently holding concurrency slots. Used to enrich
|
|
922
|
+
* the slot-acquisition timeout error so the calling agent can decide
|
|
923
|
+
* which stale forks to cancel rather than helplessly waiting on the
|
|
924
|
+
* demand-driven reaper.
|
|
925
|
+
*/
|
|
926
|
+
private describeSlotHolders(): string[] {
|
|
927
|
+
const lines: string[] = [];
|
|
928
|
+
const now = Date.now();
|
|
929
|
+
for (const sa of this.activeSubagents.values()) {
|
|
930
|
+
if (sa.status !== 'running') continue;
|
|
931
|
+
const silentS = Math.floor((now - sa.lastActivityAt) / 1000);
|
|
932
|
+
const runtimeS = Math.floor((now - sa.startedAt) / 1000);
|
|
933
|
+
const live = this.liveSubagents.get(sa.name);
|
|
934
|
+
const streamState = live?.currentStream ? 'streaming' :
|
|
935
|
+
live?.pendingToolCalls?.length ? `awaiting ${live.pendingToolCalls.length} tool(s)` :
|
|
936
|
+
'idle';
|
|
937
|
+
lines.push(
|
|
938
|
+
`${sa.name} [${sa.type}] runtime=${runtimeS}s silent=${silentS}s ${streamState}`
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
return lines;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Scan for zombie subagents and force-release their concurrency slots.
|
|
946
|
+
* A zombie is a subagent in 'running' status whose `lastActivityAt` is
|
|
947
|
+
* older than ZOMBIE_THRESHOLD_MS AND has no active inference stream AND
|
|
948
|
+
* no pending tool calls. Using `lastActivityAt` instead of `startedAt`
|
|
949
|
+
* lets us distinguish a 10-hour-but-progressing subagent from one that
|
|
950
|
+
* has been silently stuck for hours — the latter is what kept locking
|
|
951
|
+
* concurrency slots for days in production.
|
|
952
|
+
*
|
|
953
|
+
* Returns the number of slots reclaimed.
|
|
954
|
+
*/
|
|
955
|
+
private reclaimZombieSlots(): number {
|
|
956
|
+
// Postmortem 2026-05-28 F3: bumped from 30_000ms — 30s was too tight for
|
|
957
|
+
// Opus on 100–165K-token contexts with rate-limited MCP tools; single-
|
|
958
|
+
// round TTFT routinely exceeds it. The `requestInFlightSince` guard
|
|
959
|
+
// (below) is the primary defence; the threshold bump is belt-and-braces
|
|
960
|
+
// for legitimate post-request pauses.
|
|
961
|
+
const ZOMBIE_THRESHOLD_MS = 120_000;
|
|
962
|
+
let reclaimed = 0;
|
|
963
|
+
|
|
964
|
+
for (const [displayName, live] of this.liveSubagents) {
|
|
965
|
+
let entry: ActiveSubagent | undefined;
|
|
966
|
+
for (const e of this.activeSubagents.values()) {
|
|
967
|
+
if (e.name === displayName) { entry = e; break; }
|
|
968
|
+
}
|
|
969
|
+
if (!entry || entry.status !== 'running') continue;
|
|
970
|
+
|
|
971
|
+
const silentMs = Date.now() - entry.lastActivityAt;
|
|
972
|
+
// Postmortem 2026-05-28 F3: `!live.requestInFlightSince` protects the
|
|
973
|
+
// "request dispatched, awaiting first token" window. Before this, the
|
|
974
|
+
// guard `!currentStream && pendingToolCalls.length===0` was true while
|
|
975
|
+
// the next round's request was on the wire, and the reaper killed
|
|
976
|
+
// progressing agents mid-request. Forensic signature in production:
|
|
977
|
+
// last message in store is an unconsumed `tool_result`.
|
|
978
|
+
const isZombie = silentMs > ZOMBIE_THRESHOLD_MS
|
|
979
|
+
&& !live.currentStream
|
|
980
|
+
&& live.pendingToolCalls.length === 0
|
|
981
|
+
&& !live.requestInFlightSince;
|
|
982
|
+
|
|
983
|
+
if (isZombie) {
|
|
984
|
+
const elapsedTotal = Date.now() - entry.startedAt;
|
|
985
|
+
console.error(
|
|
986
|
+
`[subagent] Reclaiming zombie slot: "${displayName}" ` +
|
|
987
|
+
`(silent for ${(silentMs / 1000).toFixed(0)}s, total runtime ` +
|
|
988
|
+
`${(elapsedTotal / 1000).toFixed(0)}s, no active stream)`
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
// Cancel the zombie's framework agent
|
|
992
|
+
try {
|
|
993
|
+
const agent = this.getFramework().getAgent(live.frameworkAgentName);
|
|
994
|
+
if (agent) agent.cancelStream();
|
|
995
|
+
} catch { /* best-effort */ }
|
|
996
|
+
|
|
997
|
+
// Cancel via cancellation handle (unblocks the Promise.race in runSpawn/runFork)
|
|
998
|
+
const handle = this.cancellationHandles.get(displayName);
|
|
999
|
+
if (handle) {
|
|
1000
|
+
const partial = live.currentStream ?? '';
|
|
1001
|
+
handle.reject(new SubagentTerminated(
|
|
1002
|
+
'zombie',
|
|
1003
|
+
partial,
|
|
1004
|
+
`Zombie detected: "${displayName}" silent for ${(silentMs / 1000).toFixed(0)}s ` +
|
|
1005
|
+
`(total runtime ${(elapsedTotal / 1000).toFixed(0)}s). Slot reclaimed.`,
|
|
1006
|
+
));
|
|
1007
|
+
this.cancellationHandles.delete(displayName);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Postmortem 2026-05-28 P1 #4: zombie reclaim is terminal-but-benign,
|
|
1011
|
+
// not a fault. Land on 'cancelled' so the TUI and the reducer-driven
|
|
1012
|
+
// web tree agree (the reducer maps the trace-side `inference:aborted`
|
|
1013
|
+
// emitted by the cancellation handle to 'cancelled' as well).
|
|
1014
|
+
entry.status = 'cancelled';
|
|
1015
|
+
entry.completedAt = Date.now();
|
|
1016
|
+
entry.statusMessage = 'zombie — slot reclaimed';
|
|
1017
|
+
|
|
1018
|
+
// Release the slot (the finally block in runSpawn/runFork will also
|
|
1019
|
+
// call releaseSlot, but that's safe — activeConcurrent just goes to
|
|
1020
|
+
// max(0, activeConcurrent-1) effectively)
|
|
1021
|
+
this.activeConcurrent = Math.max(0, this.activeConcurrent - 1);
|
|
1022
|
+
reclaimed++;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return reclaimed;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
private releaseSlot(): void {
|
|
1030
|
+
if (this.activeConcurrent <= 0) return; // Guard against double-release (e.g., zombie reclamation + finally)
|
|
1031
|
+
this.activeConcurrent--;
|
|
1032
|
+
if (this.waitQueue.length > 0 && this.activeConcurrent < this.effectiveConcurrent) {
|
|
1033
|
+
this.activeConcurrent++;
|
|
1034
|
+
this.waitQueue.shift()!();
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** Format a concurrency notice for tool results (empty string if no wait). */
|
|
1039
|
+
private concurrencyNotice(waitedMs: number): string {
|
|
1040
|
+
if (waitedMs <= 0) return '';
|
|
1041
|
+
const secs = (waitedMs / 1000).toFixed(1);
|
|
1042
|
+
return `[Concurrency notice: this agent waited ${secs}s for a slot ` +
|
|
1043
|
+
`(${this.effectiveConcurrent} concurrent limit). ` +
|
|
1044
|
+
`To avoid delays, limit parallel forks/spawns to ${this.effectiveConcurrent}.]\n\n`;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/** Call on successful subagent completion — gradually recovers concurrency. */
|
|
1048
|
+
private onSubagentSuccess(): void {
|
|
1049
|
+
this.consecutiveSuccesses++;
|
|
1050
|
+
// After 3 consecutive successes, try increasing by 1
|
|
1051
|
+
if (this.consecutiveSuccesses >= 3 && this.effectiveConcurrent < this.configuredMaxConcurrent) {
|
|
1052
|
+
this.effectiveConcurrent++;
|
|
1053
|
+
this.consecutiveSuccesses = 0;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** Call on rate limit error — halves concurrency and applies cooldown. */
|
|
1058
|
+
private async onRateLimitHit(): Promise<void> {
|
|
1059
|
+
const prev = this.effectiveConcurrent;
|
|
1060
|
+
this.effectiveConcurrent = Math.max(1, Math.floor(this.effectiveConcurrent / 2));
|
|
1061
|
+
this.consecutiveSuccesses = 0;
|
|
1062
|
+
this.lastRateLimitAt = Date.now();
|
|
1063
|
+
console.error(
|
|
1064
|
+
`[subagent] Rate limit hit — concurrency ${prev} → ${this.effectiveConcurrent}, ` +
|
|
1065
|
+
`cooling down ${this.rateLimitCooldownMs}ms`
|
|
1066
|
+
);
|
|
1067
|
+
await new Promise(resolve => setTimeout(resolve, this.rateLimitCooldownMs));
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
private isRateLimitError(err: unknown): boolean {
|
|
1071
|
+
if (!(err instanceof Error)) return false;
|
|
1072
|
+
const msg = err.message.toLowerCase();
|
|
1073
|
+
return msg.includes('rate') || msg.includes('429') || msg.includes('too many');
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** Transient = worth retrying the whole subagent from scratch. */
|
|
1077
|
+
private isTransientError(err: Error): boolean {
|
|
1078
|
+
const msg = err.message.toLowerCase();
|
|
1079
|
+
return (
|
|
1080
|
+
msg.includes('timeout') ||
|
|
1081
|
+
msg.includes('idle') ||
|
|
1082
|
+
msg.includes('econnreset') ||
|
|
1083
|
+
msg.includes('socket hang up') ||
|
|
1084
|
+
msg.includes('network') ||
|
|
1085
|
+
msg.includes('stream aborted') ||
|
|
1086
|
+
msg.includes('overloaded') ||
|
|
1087
|
+
msg.includes('529') ||
|
|
1088
|
+
msg.includes('500') ||
|
|
1089
|
+
msg.includes('502') ||
|
|
1090
|
+
msg.includes('503') ||
|
|
1091
|
+
this.isRateLimitError(err)
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** Set concurrency ceiling at runtime. Also raises effective if below new ceiling. */
|
|
1096
|
+
setConcurrency(n: number): void {
|
|
1097
|
+
this.configuredMaxConcurrent = Math.max(1, n);
|
|
1098
|
+
if (this.effectiveConcurrent > this.configuredMaxConcurrent) {
|
|
1099
|
+
this.effectiveConcurrent = this.configuredMaxConcurrent;
|
|
1100
|
+
}
|
|
1101
|
+
// If we were throttled below the new ceiling, let waiters through
|
|
1102
|
+
while (this.waitQueue.length > 0 && this.activeConcurrent < this.effectiveConcurrent) {
|
|
1103
|
+
this.activeConcurrent++;
|
|
1104
|
+
this.waitQueue.shift()!();
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** Get current concurrency status for observability. */
|
|
1109
|
+
getConcurrencyStatus(): { configured: number; effective: number; active: number; queued: number } {
|
|
1110
|
+
return {
|
|
1111
|
+
configured: this.configuredMaxConcurrent,
|
|
1112
|
+
effective: this.effectiveConcurrent,
|
|
1113
|
+
active: this.activeConcurrent,
|
|
1114
|
+
queued: this.waitQueue.length,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// =========================================================================
|
|
1119
|
+
// Subagent Cancellation
|
|
1120
|
+
// =========================================================================
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Force-stop a running subagent. Aborts the active HTTP stream and
|
|
1124
|
+
* causes runSpawn/runFork to return with a "[Stopped by user]" result.
|
|
1125
|
+
* Returns true if the subagent was found and cancelled.
|
|
1126
|
+
*/
|
|
1127
|
+
cancelSubagent(displayName: string): boolean {
|
|
1128
|
+
// Cancel children first (bottom-up) so their results propagate before the parent dies
|
|
1129
|
+
this.cancelChildren(displayName);
|
|
1130
|
+
|
|
1131
|
+
const handle = this.cancellationHandles.get(displayName);
|
|
1132
|
+
if (!handle) return false;
|
|
1133
|
+
|
|
1134
|
+
// Abort the active inference stream (cancels the HTTP request)
|
|
1135
|
+
const live = this.liveSubagents.get(displayName);
|
|
1136
|
+
const partial = live?.currentStream ?? '';
|
|
1137
|
+
if (live) {
|
|
1138
|
+
try {
|
|
1139
|
+
const agent = this.getFramework().getAgent(live.frameworkAgentName);
|
|
1140
|
+
if (agent) agent.cancelStream();
|
|
1141
|
+
} catch { /* best-effort */ }
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Unblock the Promise.race in runSpawn/runFork
|
|
1145
|
+
handle.reject(new SubagentTerminated('cancelled', partial, `Subagent "${displayName}" cancelled by user`));
|
|
1146
|
+
return true;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Cancel all running subagents (e.g. on user Esc).
|
|
1151
|
+
* Returns the number of subagents cancelled.
|
|
1152
|
+
*/
|
|
1153
|
+
cancelAll(): number {
|
|
1154
|
+
// Collect all cancellable names first to avoid mutation during iteration
|
|
1155
|
+
const names = [...this.cancellationHandles.keys()];
|
|
1156
|
+
let count = 0;
|
|
1157
|
+
for (const name of names) {
|
|
1158
|
+
if (this.cancelSubagent(name)) count++;
|
|
1159
|
+
}
|
|
1160
|
+
return count;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Compute the set of descendant display names rooted at the given framework
|
|
1165
|
+
* agent name. Walks `parentMap` (displayName → callerFrameworkAgentName)
|
|
1166
|
+
* top-down. Excludes the root itself.
|
|
1167
|
+
*
|
|
1168
|
+
* Postmortem 2026-05-28 P3 #8: used by peek() and gatherContext() to
|
|
1169
|
+
* scope observability surfaces to the caller's subtree. Prevents peer
|
|
1170
|
+
* agents from inspecting each other and saturating context with
|
|
1171
|
+
* cross-fleet roster information they have no legitimate use for.
|
|
1172
|
+
*/
|
|
1173
|
+
private getDescendantDisplayNames(rootFrameworkAgentName: string): Set<string> {
|
|
1174
|
+
const result = new Set<string>();
|
|
1175
|
+
const queue: string[] = [rootFrameworkAgentName];
|
|
1176
|
+
while (queue.length > 0) {
|
|
1177
|
+
const parentFw = queue.shift()!;
|
|
1178
|
+
for (const [childDisplayName, parentName] of this.parentMap) {
|
|
1179
|
+
if (parentName === parentFw && !result.has(childDisplayName)) {
|
|
1180
|
+
result.add(childDisplayName);
|
|
1181
|
+
const childLive = this.liveSubagents.get(childDisplayName);
|
|
1182
|
+
if (childLive) queue.push(childLive.frameworkAgentName);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
return result;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Cancel all children (direct + transitive) of the given display name.
|
|
1191
|
+
* Uses the parentMap to find descendants via framework agent names.
|
|
1192
|
+
*/
|
|
1193
|
+
private cancelChildren(displayName: string): void {
|
|
1194
|
+
const live = this.liveSubagents.get(displayName);
|
|
1195
|
+
if (!live) return;
|
|
1196
|
+
|
|
1197
|
+
// Find direct children: entries in parentMap whose parent is this agent's framework name
|
|
1198
|
+
const frameworkName = live.frameworkAgentName;
|
|
1199
|
+
const children: string[] = [];
|
|
1200
|
+
for (const [childName, parentFrameworkName] of this.parentMap) {
|
|
1201
|
+
if (parentFrameworkName === frameworkName) {
|
|
1202
|
+
children.push(childName);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Cancel each child (which recursively cancels its children)
|
|
1207
|
+
for (const child of children) {
|
|
1208
|
+
this.cancelSubagent(child);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// =========================================================================
|
|
1213
|
+
// Peek Observability
|
|
1214
|
+
// =========================================================================
|
|
1215
|
+
|
|
1216
|
+
private registerLive(
|
|
1217
|
+
displayName: string,
|
|
1218
|
+
frameworkAgentName: string,
|
|
1219
|
+
systemPrompt: string,
|
|
1220
|
+
contextManager: ContextManager,
|
|
1221
|
+
): void {
|
|
1222
|
+
this.liveSubagents.set(displayName, {
|
|
1223
|
+
frameworkAgentName,
|
|
1224
|
+
displayName,
|
|
1225
|
+
systemPrompt,
|
|
1226
|
+
contextManager,
|
|
1227
|
+
currentStream: '',
|
|
1228
|
+
pendingToolCalls: [],
|
|
1229
|
+
activeCallIds: new Set(),
|
|
1230
|
+
});
|
|
1231
|
+
this.frameworkNameIndex.set(frameworkAgentName, displayName);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
private unregisterLive(displayName: string, frameworkAgentName: string): void {
|
|
1235
|
+
// Clean up callId index entries for this subagent
|
|
1236
|
+
const live = this.liveSubagents.get(displayName);
|
|
1237
|
+
if (live) {
|
|
1238
|
+
for (const callId of live.activeCallIds) {
|
|
1239
|
+
this.callIdIndex.delete(callId);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
this.liveSubagents.delete(displayName);
|
|
1243
|
+
this.frameworkNameIndex.delete(frameworkAgentName);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/** Fan out a stream event to all subscribers for this subagent + wildcard. */
|
|
1247
|
+
private emit(displayName: string, event: SubagentStreamEvent): void {
|
|
1248
|
+
for (const key of [displayName, '*']) {
|
|
1249
|
+
const subs = this.streamSubscribers.get(key);
|
|
1250
|
+
if (!subs) continue;
|
|
1251
|
+
for (const cb of subs) {
|
|
1252
|
+
try { cb(event); } catch { /* subscriber error — don't break the loop */ }
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Subscribe to a running subagent's live stream. Receives all inference
|
|
1259
|
+
* and tool events as they happen. Returns an unsubscribe function.
|
|
1260
|
+
*
|
|
1261
|
+
* If name is '*', subscribes to events from ALL subagents (events are
|
|
1262
|
+
* the same type — use peek() to get the subagent name if needed).
|
|
1263
|
+
*/
|
|
1264
|
+
onPeekStream(name: string, callback: SubagentStreamCallback): () => void {
|
|
1265
|
+
if (!this.streamSubscribers.has(name)) {
|
|
1266
|
+
this.streamSubscribers.set(name, new Set());
|
|
1267
|
+
}
|
|
1268
|
+
this.streamSubscribers.get(name)!.add(callback);
|
|
1269
|
+
|
|
1270
|
+
return () => {
|
|
1271
|
+
const subs = this.streamSubscribers.get(name);
|
|
1272
|
+
if (subs) {
|
|
1273
|
+
subs.delete(callback);
|
|
1274
|
+
if (subs.size === 0) this.streamSubscribers.delete(name);
|
|
1275
|
+
}
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Peek at a running subagent's live state: full context, streaming output,
|
|
1281
|
+
* pending tool calls. Returns null if the subagent is not running.
|
|
1282
|
+
* If name is omitted, returns snapshots for all running subagents.
|
|
1283
|
+
*
|
|
1284
|
+
* Postmortem 2026-05-28 P3 #8: when a `callerFrameworkAgentName` is
|
|
1285
|
+
* supplied (the normal tool-call path threads it through `handlePeek`),
|
|
1286
|
+
* the result is scoped to the caller's descendants — peers/cousins are
|
|
1287
|
+
* filtered out. Internal callers without a known identity (omit the arg)
|
|
1288
|
+
* keep the global view for backward compatibility.
|
|
1289
|
+
*/
|
|
1290
|
+
async peek(name?: string, callerFrameworkAgentName?: string): Promise<SubagentPeekSnapshot[]> {
|
|
1291
|
+
const allowed = callerFrameworkAgentName
|
|
1292
|
+
? this.getDescendantDisplayNames(callerFrameworkAgentName)
|
|
1293
|
+
: null;
|
|
1294
|
+
if (name) {
|
|
1295
|
+
if (allowed && !allowed.has(name)) return [];
|
|
1296
|
+
const snapshot = await this.peekOne(name);
|
|
1297
|
+
return snapshot ? [snapshot] : [];
|
|
1298
|
+
}
|
|
1299
|
+
const results: SubagentPeekSnapshot[] = [];
|
|
1300
|
+
for (const displayName of this.liveSubagents.keys()) {
|
|
1301
|
+
if (allowed && !allowed.has(displayName)) continue;
|
|
1302
|
+
const snapshot = await this.peekOne(displayName);
|
|
1303
|
+
if (snapshot) results.push(snapshot);
|
|
1304
|
+
}
|
|
1305
|
+
return results;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
private async peekOne(displayName: string): Promise<SubagentPeekSnapshot | null> {
|
|
1309
|
+
const live = this.liveSubagents.get(displayName);
|
|
1310
|
+
if (!live) return null;
|
|
1311
|
+
|
|
1312
|
+
// Find the matching ActiveSubagent entry for status/metadata
|
|
1313
|
+
let entry: ActiveSubagent | undefined;
|
|
1314
|
+
for (const e of this.activeSubagents.values()) {
|
|
1315
|
+
if (e.name === displayName) { entry = e; break; }
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
let messageCount = 0;
|
|
1319
|
+
let lastMessageSnippet = '';
|
|
1320
|
+
try {
|
|
1321
|
+
const compiled = await live.contextManager.compile();
|
|
1322
|
+
messageCount = compiled.messages.length;
|
|
1323
|
+
// Extract a short snippet from the last message for observability
|
|
1324
|
+
// without dumping the entire context into the caller's window.
|
|
1325
|
+
if (compiled.messages.length > 0) {
|
|
1326
|
+
const last = compiled.messages[compiled.messages.length - 1];
|
|
1327
|
+
const textBlocks = last.content
|
|
1328
|
+
.filter((b: ContentBlock) => b.type === 'text')
|
|
1329
|
+
.map((b: ContentBlock) => (b as { type: 'text'; text: string }).text);
|
|
1330
|
+
const fullText = textBlocks.join(' ');
|
|
1331
|
+
lastMessageSnippet = fullText.length > 500 ? fullText.slice(-500) : fullText;
|
|
1332
|
+
}
|
|
1333
|
+
} catch {
|
|
1334
|
+
// Context manager may be mid-modification; return what we have
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const elapsedMs = entry ? Date.now() - entry.startedAt : 0;
|
|
1338
|
+
|
|
1339
|
+
// Zombie detection. Postmortem 2026-05-28 F2: this previously keyed off
|
|
1340
|
+
// `startedAt`, which flagged any subagent >30s old as a zombie the moment
|
|
1341
|
+
// it was between tokens — saturating peers' context with false alarms and
|
|
1342
|
+
// driving an orchestrator-level retry storm. Now keyed off `lastActivityAt`
|
|
1343
|
+
// and gated by `!requestInFlightSince` to match the reaper.
|
|
1344
|
+
const ZOMBIE_THRESHOLD_MS = 120_000;
|
|
1345
|
+
const silentMs = entry ? Date.now() - entry.lastActivityAt : 0;
|
|
1346
|
+
const isZombie = (entry?.status === 'running')
|
|
1347
|
+
&& silentMs > ZOMBIE_THRESHOLD_MS
|
|
1348
|
+
&& !live.currentStream
|
|
1349
|
+
&& live.pendingToolCalls.length === 0
|
|
1350
|
+
&& !live.requestInFlightSince;
|
|
1351
|
+
|
|
1352
|
+
return {
|
|
1353
|
+
name: displayName,
|
|
1354
|
+
type: entry?.type ?? 'spawn',
|
|
1355
|
+
task: entry?.task ?? '',
|
|
1356
|
+
status: entry?.status ?? 'running',
|
|
1357
|
+
startedAt: entry?.startedAt ?? 0,
|
|
1358
|
+
elapsedMs,
|
|
1359
|
+
messageCount,
|
|
1360
|
+
lastMessageSnippet,
|
|
1361
|
+
currentStream: live.currentStream,
|
|
1362
|
+
pendingToolCalls: live.pendingToolCalls,
|
|
1363
|
+
toolCallsCount: entry?.toolCallsCount ?? 0,
|
|
1364
|
+
isZombie,
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// =========================================================================
|
|
1369
|
+
// Execution Timeout
|
|
1370
|
+
// =========================================================================
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Resolve a subagent's maxTokens budget through the cascade:
|
|
1374
|
+
* 1. per-call `maxTokens` on the fork/spawn input
|
|
1375
|
+
* 2. recipe-level `defaultMaxTokens` on this module's config
|
|
1376
|
+
* 3. parent agent's `maxTokens` (by default, subagents inherit their caller's budget)
|
|
1377
|
+
* 4. last-resort framework fallback (4096) — only reached if there's no parent at all
|
|
1378
|
+
*/
|
|
1379
|
+
private resolveMaxTokens(callMaxTokens: number | undefined, parentAgentName?: string): number {
|
|
1380
|
+
if (callMaxTokens !== undefined) return callMaxTokens;
|
|
1381
|
+
if (this.config.defaultMaxTokens !== undefined) return this.config.defaultMaxTokens;
|
|
1382
|
+
const parentName = parentAgentName ?? this.config.parentAgentName;
|
|
1383
|
+
if (parentName) {
|
|
1384
|
+
const parent = this.framework?.getAgent(parentName);
|
|
1385
|
+
if (parent) return parent.maxTokens;
|
|
1386
|
+
}
|
|
1387
|
+
return 4096;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
private withTimeout<T>(promise: Promise<T>, name: string, timeoutMs?: number): Promise<T> {
|
|
1391
|
+
if (timeoutMs === undefined) return promise;
|
|
1392
|
+
return Promise.race([
|
|
1393
|
+
promise,
|
|
1394
|
+
new Promise<never>((_, reject) =>
|
|
1395
|
+
setTimeout(
|
|
1396
|
+
() => reject(new Error(`Subagent ${name} timed out after ${timeoutMs}ms`)),
|
|
1397
|
+
timeoutMs,
|
|
1398
|
+
)
|
|
1399
|
+
),
|
|
1400
|
+
]);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// =========================================================================
|
|
1404
|
+
// Prompt Size Estimation
|
|
1405
|
+
// =========================================================================
|
|
1406
|
+
|
|
1407
|
+
private estimatePromptTokens(
|
|
1408
|
+
systemPrompt: string,
|
|
1409
|
+
messages: Array<{ content: ContentBlock[] }>,
|
|
1410
|
+
tools: ToolDefinition[],
|
|
1411
|
+
): number {
|
|
1412
|
+
let tokens = Math.ceil(systemPrompt.length / 4) + 50; // system + overhead
|
|
1413
|
+
|
|
1414
|
+
for (const msg of messages) {
|
|
1415
|
+
tokens += 50; // per-message overhead (role, formatting)
|
|
1416
|
+
for (const block of msg.content) {
|
|
1417
|
+
tokens += Math.ceil(JSON.stringify(block).length / 4);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
for (const tool of tools) {
|
|
1422
|
+
tokens += 100; // per-tool overhead
|
|
1423
|
+
tokens += Math.ceil(JSON.stringify(tool).length / 4);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
return tokens;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// =========================================================================
|
|
1430
|
+
// Tool Handlers
|
|
1431
|
+
// =========================================================================
|
|
1432
|
+
|
|
1433
|
+
private async handleSpawn(input: SpawnInput, callerAgentName?: string): Promise<ToolResult> {
|
|
1434
|
+
const callerDepth = callerAgentName ? (this.agentDepths.get(callerAgentName) ?? 0) : 0;
|
|
1435
|
+
if (callerDepth >= this.maxDepth) {
|
|
1436
|
+
return {
|
|
1437
|
+
success: false,
|
|
1438
|
+
isError: true,
|
|
1439
|
+
error: `Max subagent depth ${this.maxDepth} reached (caller at depth ${callerDepth})`,
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const parentAgentName = callerAgentName ?? this.config.parentAgentName ?? 'agent';
|
|
1444
|
+
|
|
1445
|
+
// Sync mode: block until completion, but detachable mid-flight.
|
|
1446
|
+
// Default timeout applies (600s) — auto-detaches to background.
|
|
1447
|
+
if (input.sync) {
|
|
1448
|
+
const timeoutMs = input.timeoutMs ?? this.maxExecutionMs;
|
|
1449
|
+
const promise = this.runSpawn(input, callerAgentName, callerDepth, timeoutMs);
|
|
1450
|
+
const result = await this.runDetachable(input.name, 'spawn', promise, parentAgentName, input.timeoutMs);
|
|
1451
|
+
return result;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Async mode (default): fire-and-forget, deliver result as message.
|
|
1455
|
+
// No default timeout — async agents run until they finish unless
|
|
1456
|
+
// the caller explicitly sets timeoutMs.
|
|
1457
|
+
const promise = this.runSpawn(input, callerAgentName, callerDepth, input.timeoutMs);
|
|
1458
|
+
this.asyncHandles.set(input.name, { name: input.name, type: 'spawn', promise, parentAgentName });
|
|
1459
|
+
|
|
1460
|
+
promise
|
|
1461
|
+
.then(result => this.deliverAsyncResult(input.name, result, parentAgentName))
|
|
1462
|
+
.catch(err => this.deliverAsyncError(input.name, err, parentAgentName));
|
|
1463
|
+
|
|
1464
|
+
return { success: true, data: `Subagent '${input.name}' spawned. Running in background.` };
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
private async handleFork(input: ForkInput, callerAgentName?: string, callToolUseId?: string): Promise<ToolResult> {
|
|
1468
|
+
const callerDepth = callerAgentName ? (this.agentDepths.get(callerAgentName) ?? 0) : 0;
|
|
1469
|
+
if (callerDepth >= this.maxDepth) {
|
|
1470
|
+
return {
|
|
1471
|
+
success: false,
|
|
1472
|
+
isError: true,
|
|
1473
|
+
error: `Max subagent depth ${this.maxDepth} reached (caller at depth ${callerDepth})`,
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const parentAgentName = callerAgentName ?? this.config.parentAgentName ?? 'agent';
|
|
1478
|
+
|
|
1479
|
+
// Sync mode: block until completion, but detachable mid-flight.
|
|
1480
|
+
// Default timeout applies (600s) — auto-detaches to background.
|
|
1481
|
+
if (input.sync) {
|
|
1482
|
+
const timeoutMs = input.timeoutMs ?? this.maxExecutionMs;
|
|
1483
|
+
const promise = this.runFork(input, callerAgentName, callerDepth, timeoutMs, callToolUseId);
|
|
1484
|
+
const result = await this.runDetachable(input.name, 'fork', promise, parentAgentName, input.timeoutMs);
|
|
1485
|
+
return result;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// Async mode (default): fire-and-forget, deliver result as message.
|
|
1489
|
+
// No default timeout — async agents run until they finish unless
|
|
1490
|
+
// the caller explicitly sets timeoutMs.
|
|
1491
|
+
const promise = this.runFork(input, callerAgentName, callerDepth, input.timeoutMs, callToolUseId);
|
|
1492
|
+
this.asyncHandles.set(input.name, { name: input.name, type: 'fork', promise, parentAgentName });
|
|
1493
|
+
|
|
1494
|
+
promise
|
|
1495
|
+
.then(result => this.deliverAsyncResult(input.name, result, parentAgentName))
|
|
1496
|
+
.catch(err => this.deliverAsyncError(input.name, err, parentAgentName));
|
|
1497
|
+
|
|
1498
|
+
return { success: true, data: `Subagent '${input.name}' forked. Running in background.` };
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
private deliverAsyncResult(name: string, result: SubagentResult, parentAgentName: string): void {
|
|
1502
|
+
this.asyncHandles.delete(name);
|
|
1503
|
+
if (!this.ctx) return;
|
|
1504
|
+
|
|
1505
|
+
this.ctx.addMessage('user', [{
|
|
1506
|
+
type: 'text',
|
|
1507
|
+
text: `[Subagent '${name}' returned]\n\n${result.summary}`,
|
|
1508
|
+
}]);
|
|
1509
|
+
this.ctx.pushEvent({
|
|
1510
|
+
type: 'inference-request',
|
|
1511
|
+
agentName: parentAgentName,
|
|
1512
|
+
reason: `subagent-completed:${name}`,
|
|
1513
|
+
source: 'subagent',
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
private deliverAsyncError(name: string, err: unknown, parentAgentName: string): void {
|
|
1518
|
+
this.asyncHandles.delete(name);
|
|
1519
|
+
if (!this.ctx) return;
|
|
1520
|
+
|
|
1521
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1522
|
+
this.ctx.addMessage('user', [{
|
|
1523
|
+
type: 'text',
|
|
1524
|
+
text: `[Subagent '${name}' failed]\n\nError: ${message}`,
|
|
1525
|
+
}]);
|
|
1526
|
+
this.ctx.pushEvent({
|
|
1527
|
+
type: 'inference-request',
|
|
1528
|
+
agentName: parentAgentName,
|
|
1529
|
+
reason: `subagent-failed:${name}`,
|
|
1530
|
+
source: 'subagent',
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// =========================================================================
|
|
1535
|
+
// Detachable sync → async transition
|
|
1536
|
+
// =========================================================================
|
|
1537
|
+
|
|
1538
|
+
/**
|
|
1539
|
+
* Run a sync subagent with the ability to detach mid-flight.
|
|
1540
|
+
* Returns a ToolResult — either the completed result (if it finishes in time)
|
|
1541
|
+
* or a "moved to background" acknowledgment (if detached by user or timeout).
|
|
1542
|
+
*/
|
|
1543
|
+
private async runDetachable(
|
|
1544
|
+
name: string,
|
|
1545
|
+
type: 'spawn' | 'fork',
|
|
1546
|
+
promise: Promise<SubagentResult>,
|
|
1547
|
+
parentAgentName: string,
|
|
1548
|
+
autoDetachMs?: number,
|
|
1549
|
+
): Promise<ToolResult> {
|
|
1550
|
+
let detachResolve: ((value: 'detached') => void) | null = null;
|
|
1551
|
+
const detachPromise = new Promise<'detached'>(resolve => { detachResolve = resolve; });
|
|
1552
|
+
|
|
1553
|
+
const handle: DetachableHandle = {
|
|
1554
|
+
name,
|
|
1555
|
+
type,
|
|
1556
|
+
promise,
|
|
1557
|
+
parentAgentName,
|
|
1558
|
+
detach: () => detachResolve?.('detached'),
|
|
1559
|
+
};
|
|
1560
|
+
this.detachableHandles.set(name, handle);
|
|
1561
|
+
|
|
1562
|
+
// Optional auto-detach timeout (sync → async after N ms)
|
|
1563
|
+
let autoTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1564
|
+
if (autoDetachMs !== undefined) {
|
|
1565
|
+
autoTimer = setTimeout(() => {
|
|
1566
|
+
if (this.detachableHandles.has(name)) {
|
|
1567
|
+
handle.detach();
|
|
1568
|
+
}
|
|
1569
|
+
}, autoDetachMs);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
type RaceResult =
|
|
1573
|
+
| { kind: 'completed'; result: SubagentResult }
|
|
1574
|
+
| { kind: 'error'; error: unknown }
|
|
1575
|
+
| { kind: 'detached' };
|
|
1576
|
+
|
|
1577
|
+
try {
|
|
1578
|
+
const winner: RaceResult = await Promise.race([
|
|
1579
|
+
promise.then(
|
|
1580
|
+
(result): RaceResult => ({ kind: 'completed', result }),
|
|
1581
|
+
(err): RaceResult => ({ kind: 'error', error: err }),
|
|
1582
|
+
),
|
|
1583
|
+
detachPromise.then((): RaceResult => ({ kind: 'detached' })),
|
|
1584
|
+
]);
|
|
1585
|
+
|
|
1586
|
+
if (autoTimer) clearTimeout(autoTimer);
|
|
1587
|
+
this.detachableHandles.delete(name);
|
|
1588
|
+
|
|
1589
|
+
if (winner.kind === 'completed') {
|
|
1590
|
+
return { success: true, data: winner.result };
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
if (winner.kind === 'error') {
|
|
1594
|
+
const err = winner.error;
|
|
1595
|
+
return {
|
|
1596
|
+
success: false,
|
|
1597
|
+
isError: true,
|
|
1598
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// Detached: transition to async — wire up result delivery
|
|
1603
|
+
this.asyncHandles.set(name, { name, type, promise, parentAgentName });
|
|
1604
|
+
promise
|
|
1605
|
+
.then(result => this.deliverAsyncResult(name, result, parentAgentName))
|
|
1606
|
+
.catch(err => this.deliverAsyncError(name, err, parentAgentName));
|
|
1607
|
+
|
|
1608
|
+
return {
|
|
1609
|
+
success: true,
|
|
1610
|
+
data: `Subagent '${name}' moved to background. Results will be delivered as a message when complete.`,
|
|
1611
|
+
};
|
|
1612
|
+
} catch {
|
|
1613
|
+
if (autoTimer) clearTimeout(autoTimer);
|
|
1614
|
+
this.detachableHandles.delete(name);
|
|
1615
|
+
return { success: false, isError: true, error: `Unexpected error in detachable handler for '${name}'` };
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* Detach a currently-blocking sync subagent, converting it to async.
|
|
1621
|
+
* Returns true if the subagent was found and detached.
|
|
1622
|
+
*/
|
|
1623
|
+
detachSubagent(name: string): boolean {
|
|
1624
|
+
const handle = this.detachableHandles.get(name);
|
|
1625
|
+
if (!handle) return false;
|
|
1626
|
+
handle.detach();
|
|
1627
|
+
return true;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/**
|
|
1631
|
+
* Detach all currently-blocking sync subagents.
|
|
1632
|
+
* Returns the number of subagents detached.
|
|
1633
|
+
*/
|
|
1634
|
+
detachAll(): number {
|
|
1635
|
+
let count = 0;
|
|
1636
|
+
for (const handle of this.detachableHandles.values()) {
|
|
1637
|
+
handle.detach();
|
|
1638
|
+
count++;
|
|
1639
|
+
}
|
|
1640
|
+
return count;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* Check if any sync subagents are currently blocking (and thus detachable).
|
|
1645
|
+
*/
|
|
1646
|
+
hasDetachable(): boolean {
|
|
1647
|
+
return this.detachableHandles.size > 0;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
private handleHud(input: { enabled: boolean }): ToolResult {
|
|
1651
|
+
if (!this.ctx) return { success: false, isError: true, error: 'Module not started' };
|
|
1652
|
+
const persisted = this.ctx.getState<{ agents?: unknown; hudEnabled?: boolean }>() ?? {};
|
|
1653
|
+
this.ctx.setState({ ...persisted, hudEnabled: input.enabled });
|
|
1654
|
+
return { success: true, data: { hudEnabled: input.enabled } };
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
private handleConcurrency(input: { maxConcurrent?: number }): ToolResult {
|
|
1658
|
+
if (input.maxConcurrent !== undefined) {
|
|
1659
|
+
this.setConcurrency(input.maxConcurrent);
|
|
1660
|
+
}
|
|
1661
|
+
return { success: true, data: this.getConcurrencyStatus() };
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
private async handlePeek(input: { name?: string }, caller?: string): Promise<ToolResult> {
|
|
1665
|
+
const snapshots = await this.peek(input.name, caller);
|
|
1666
|
+
if (snapshots.length === 0) {
|
|
1667
|
+
return {
|
|
1668
|
+
success: true,
|
|
1669
|
+
data: { message: input.name ? `No running subagent named '${input.name}'` : 'No running subagents' },
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
return { success: true, data: snapshots };
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// =========================================================================
|
|
1676
|
+
// Subagent Execution
|
|
1677
|
+
// =========================================================================
|
|
1678
|
+
|
|
1679
|
+
private async runSpawn(input: SpawnInput, _callerAgentName?: string, callerDepth = 0, executionTimeoutMs?: number): Promise<SubagentResult> {
|
|
1680
|
+
const { waitedMs } = await this.acquireSlot();
|
|
1681
|
+
const childDepth = callerDepth + 1;
|
|
1682
|
+
|
|
1683
|
+
const now = Date.now();
|
|
1684
|
+
const entry: ActiveSubagent = {
|
|
1685
|
+
name: input.name, type: 'spawn', task: input.task,
|
|
1686
|
+
status: 'running', startedAt: now, lastActivityAt: now,
|
|
1687
|
+
toolCallsCount: 0, findingsCount: 0,
|
|
1688
|
+
};
|
|
1689
|
+
const entryKey = `spawn-${input.name}`;
|
|
1690
|
+
this.activeSubagents.set(entryKey, entry);
|
|
1691
|
+
if (_callerAgentName) this.parentMap.set(input.name, _callerAgentName);
|
|
1692
|
+
this.persistState();
|
|
1693
|
+
|
|
1694
|
+
try {
|
|
1695
|
+
const framework = this.getFramework();
|
|
1696
|
+
const model = input.model ?? this.config.defaultModel ?? 'claude-haiku-4-5-20251001';
|
|
1697
|
+
let lastError: Error | null = null;
|
|
1698
|
+
|
|
1699
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
1700
|
+
const agentName = `spawn-${input.name}-${Date.now()}`;
|
|
1701
|
+
const { agent, contextManager, cleanup } = await framework.createEphemeralAgent({
|
|
1702
|
+
name: agentName,
|
|
1703
|
+
model,
|
|
1704
|
+
systemPrompt: input.systemPrompt,
|
|
1705
|
+
maxTokens: this.resolveMaxTokens(input.maxTokens, _callerAgentName),
|
|
1706
|
+
maxStreamTokens: 500_000,
|
|
1707
|
+
strategy: new KnowledgeStrategy({
|
|
1708
|
+
headWindowTokens: 2_000,
|
|
1709
|
+
recentWindowTokens: 80_000,
|
|
1710
|
+
compressionModel: model,
|
|
1711
|
+
autoTickOnNewMessage: true,
|
|
1712
|
+
maxMessageTokens: 10_000,
|
|
1713
|
+
}),
|
|
1714
|
+
allowedTools: this.filterToolNames(input.tools, callerDepth),
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
// Track depth for recursive fork/spawn calls from this agent
|
|
1718
|
+
this.agentDepths.set(agentName, childDepth);
|
|
1719
|
+
|
|
1720
|
+
// Register live state for peek observability
|
|
1721
|
+
this.registerLive(input.name, agentName, input.systemPrompt, contextManager);
|
|
1722
|
+
|
|
1723
|
+
try {
|
|
1724
|
+
contextManager.addMessage('user', [{ type: 'text', text: input.task }]);
|
|
1725
|
+
|
|
1726
|
+
// Pre-validate prompt size
|
|
1727
|
+
const { messages } = await contextManager.compile();
|
|
1728
|
+
const tools = framework.getAllTools().filter(t => agent.canUseTool(t.name));
|
|
1729
|
+
const est = this.estimatePromptTokens(agent.systemPrompt, messages, tools);
|
|
1730
|
+
if (est > this.maxPromptTokens) {
|
|
1731
|
+
throw new Error(
|
|
1732
|
+
`Prompt too large for subagent ${input.name}: ~${est} tokens ` +
|
|
1733
|
+
`(limit: ${this.maxPromptTokens}). Reduce context or task size.`
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// Race execution against both timeout and user cancellation
|
|
1738
|
+
const cancelPromise = new Promise<never>((_, reject) => {
|
|
1739
|
+
this.cancellationHandles.set(input.name, { reject });
|
|
1740
|
+
});
|
|
1741
|
+
|
|
1742
|
+
let { speech, toolCallsCount } = await Promise.race([
|
|
1743
|
+
this.withTimeout(
|
|
1744
|
+
framework.runEphemeralToCompletion(agent, contextManager),
|
|
1745
|
+
input.name,
|
|
1746
|
+
executionTimeoutMs,
|
|
1747
|
+
),
|
|
1748
|
+
cancelPromise,
|
|
1749
|
+
]);
|
|
1750
|
+
|
|
1751
|
+
this.cancellationHandles.delete(input.name);
|
|
1752
|
+
|
|
1753
|
+
// Prefer explicit return over speech capture
|
|
1754
|
+
const returned = this.returnedResults.get(input.name);
|
|
1755
|
+
if (returned) {
|
|
1756
|
+
speech = returned;
|
|
1757
|
+
this.returnedResults.delete(input.name);
|
|
1758
|
+
} else if (!speech.trim()) {
|
|
1759
|
+
speech = this.extractLastAssistantText(contextManager);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
entry.status = 'completed';
|
|
1763
|
+
entry.completedAt = Date.now();
|
|
1764
|
+
entry.toolCallsCount = toolCallsCount;
|
|
1765
|
+
this.onSubagentSuccess();
|
|
1766
|
+
const notice = this.concurrencyNotice(waitedMs);
|
|
1767
|
+
const finalSummary = notice + speech;
|
|
1768
|
+
this.emit(input.name, { type: 'done', summary: finalSummary, lastInputTokens: this.lastInputTokens.get(input.name) });
|
|
1769
|
+
return { summary: finalSummary, findings: [], issues: [], toolCallsCount };
|
|
1770
|
+
} catch (err) {
|
|
1771
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1772
|
+
this.cancellationHandles.delete(input.name);
|
|
1773
|
+
|
|
1774
|
+
// Non-retryable termination (user cancel, zombie reclaim, etc.).
|
|
1775
|
+
// Postmortem 2026-05-28 P1 #4: terminal-but-benign — 'cancelled',
|
|
1776
|
+
// not 'completed'. Before this, the TUI subagent list rendered these
|
|
1777
|
+
// as "done" while the reducer-driven web tree rendered them red.
|
|
1778
|
+
if (lastError instanceof SubagentTerminated) {
|
|
1779
|
+
entry.status = 'cancelled';
|
|
1780
|
+
entry.completedAt = Date.now();
|
|
1781
|
+
entry.statusMessage = lastError.reason;
|
|
1782
|
+
const notice = this.concurrencyNotice(waitedMs);
|
|
1783
|
+
const label = lastError.reason === 'cancelled' ? 'Stopped by user' : `Terminated: ${lastError.reason}`;
|
|
1784
|
+
const summary = notice + `[${label}] ` + (lastError.partialOutput || '(no output yet)');
|
|
1785
|
+
this.emit(input.name, { type: 'done', summary, lastInputTokens: this.lastInputTokens.get(input.name) });
|
|
1786
|
+
return { summary, findings: [], issues: [], toolCallsCount: entry.toolCallsCount };
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
if (this.isRateLimitError(lastError)) await this.onRateLimitHit();
|
|
1790
|
+
if (!this.isTransientError(lastError) || attempt === this.maxRetries) break;
|
|
1791
|
+
|
|
1792
|
+
const delay = Math.min(5_000 * (attempt + 1), 30_000);
|
|
1793
|
+
console.error(
|
|
1794
|
+
`[subagent] ${input.name} attempt ${attempt + 1}/${this.maxRetries + 1} failed: ` +
|
|
1795
|
+
`${lastError.message}. Restarting in ${delay}ms...`
|
|
1796
|
+
);
|
|
1797
|
+
entry.statusMessage = `Retry ${attempt + 1}: ${lastError.message}`;
|
|
1798
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
1799
|
+
} finally {
|
|
1800
|
+
this.agentDepths.delete(agentName);
|
|
1801
|
+
this.unregisterLive(input.name, agentName);
|
|
1802
|
+
cleanup();
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
entry.status = 'failed';
|
|
1807
|
+
entry.completedAt = Date.now();
|
|
1808
|
+
entry.statusMessage = lastError!.message;
|
|
1809
|
+
throw lastError!;
|
|
1810
|
+
} finally {
|
|
1811
|
+
this.persistState();
|
|
1812
|
+
this.releaseSlot();
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
private async runFork(input: ForkInput, callerAgentName?: string, callerDepth = 0, executionTimeoutMs?: number, callToolUseId?: string): Promise<SubagentResult> {
|
|
1817
|
+
const { waitedMs } = await this.acquireSlot();
|
|
1818
|
+
const childDepth = callerDepth + 1;
|
|
1819
|
+
|
|
1820
|
+
const now = Date.now();
|
|
1821
|
+
const entry: ActiveSubagent = {
|
|
1822
|
+
name: input.name, type: 'fork', task: input.task,
|
|
1823
|
+
status: 'running', startedAt: now, lastActivityAt: now,
|
|
1824
|
+
toolCallsCount: 0, findingsCount: 0,
|
|
1825
|
+
};
|
|
1826
|
+
this.activeSubagents.set(input.name, entry);
|
|
1827
|
+
if (callerAgentName) this.parentMap.set(input.name, callerAgentName);
|
|
1828
|
+
this.persistState();
|
|
1829
|
+
|
|
1830
|
+
try {
|
|
1831
|
+
const framework = this.getFramework();
|
|
1832
|
+
|
|
1833
|
+
// Dynamic parent resolution: prefer the caller agent (enables recursive forks),
|
|
1834
|
+
// fall back to the configured parent agent for backward compat.
|
|
1835
|
+
const parentAgent = callerAgentName
|
|
1836
|
+
? framework.getAgent(callerAgentName)
|
|
1837
|
+
: (this.config.parentAgentName ? framework.getAgent(this.config.parentAgentName) : null);
|
|
1838
|
+
|
|
1839
|
+
const systemPrompt = input.systemPrompt
|
|
1840
|
+
?? (parentAgent ? parentAgent.systemPrompt : 'You are a research assistant.');
|
|
1841
|
+
|
|
1842
|
+
const model = input.model ?? this.config.defaultModel ?? 'claude-haiku-4-5-20251001';
|
|
1843
|
+
let lastError: Error | null = null;
|
|
1844
|
+
|
|
1845
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
1846
|
+
// Unique agent name: include depth + timestamp to prevent collisions when
|
|
1847
|
+
// a child fork uses the same display name as its parent (e.g. both called
|
|
1848
|
+
// "fork-level-2"). Without this, the child overwrites the parent in the
|
|
1849
|
+
// framework's agents Map, and the parent's completion promise never resolves.
|
|
1850
|
+
const suffix = attempt === 0 ? `d${childDepth}-${Date.now()}` : `d${childDepth}-retry${attempt}-${Date.now()}`;
|
|
1851
|
+
const agentName = `${input.name}-${suffix}`;
|
|
1852
|
+
|
|
1853
|
+
const { agent, contextManager, cleanup } = await framework.createEphemeralAgent({
|
|
1854
|
+
name: agentName,
|
|
1855
|
+
model,
|
|
1856
|
+
systemPrompt,
|
|
1857
|
+
maxTokens: this.resolveMaxTokens(input.maxTokens, callerAgentName),
|
|
1858
|
+
maxStreamTokens: 500_000,
|
|
1859
|
+
strategy: new KnowledgeStrategy({
|
|
1860
|
+
headWindowTokens: 2_000,
|
|
1861
|
+
recentWindowTokens: 80_000,
|
|
1862
|
+
compressionModel: model,
|
|
1863
|
+
autoTickOnNewMessage: true,
|
|
1864
|
+
maxMessageTokens: 10_000,
|
|
1865
|
+
}),
|
|
1866
|
+
allowedTools: this.filterToolNames(undefined, callerDepth),
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
// Track depth for recursive fork/spawn calls from this agent
|
|
1870
|
+
this.agentDepths.set(agentName, childDepth);
|
|
1871
|
+
|
|
1872
|
+
// Register live state for peek observability
|
|
1873
|
+
this.registerLive(input.name, agentName, systemPrompt, contextManager);
|
|
1874
|
+
|
|
1875
|
+
try {
|
|
1876
|
+
// Materialise the fork's inherited context structurally:
|
|
1877
|
+
// - locate the parent's matching subagent--fork tool_use by id
|
|
1878
|
+
// - strip sibling fork tool_use blocks (and their tool_results) from
|
|
1879
|
+
// the parent's last assistant turn — they read as fleet-dispatch
|
|
1880
|
+
// evidence and convince the model it's the parent
|
|
1881
|
+
// - rewrite the matching tool_result with intention-stream framing
|
|
1882
|
+
// that names the dual-self situation explicitly
|
|
1883
|
+
// - drop everything after that tool_result (post-fork peek/wait
|
|
1884
|
+
// turns are the other big chunk of parent-coherence signal)
|
|
1885
|
+
// If parent context is compressed past the fork point and the
|
|
1886
|
+
// matching tool_use can't be located, fall back to wholesale copy
|
|
1887
|
+
// plus a synthetic intention-framed fork tool_use/tool_result.
|
|
1888
|
+
if (parentAgent) {
|
|
1889
|
+
const parentCM = parentAgent.getContextManager();
|
|
1890
|
+
const { messages: compiled } = await parentCM.compile();
|
|
1891
|
+
const transformed = callToolUseId
|
|
1892
|
+
? materialiseStructuralFork(
|
|
1893
|
+
compiled,
|
|
1894
|
+
callToolUseId,
|
|
1895
|
+
input.name,
|
|
1896
|
+
input.task,
|
|
1897
|
+
childDepth,
|
|
1898
|
+
this.maxDepth,
|
|
1899
|
+
)
|
|
1900
|
+
: null;
|
|
1901
|
+
|
|
1902
|
+
const seen = new Set<string>();
|
|
1903
|
+
const addMsg = (msg: { participant: string; content: ContentBlock[] }) => {
|
|
1904
|
+
const key = msg.participant + '\0' + JSON.stringify(msg.content);
|
|
1905
|
+
if (seen.has(key)) return;
|
|
1906
|
+
seen.add(key);
|
|
1907
|
+
const participant = msg.participant === parentAgent.name ? agentName : msg.participant;
|
|
1908
|
+
contextManager.addMessage(participant, msg.content);
|
|
1909
|
+
};
|
|
1910
|
+
|
|
1911
|
+
if (transformed) {
|
|
1912
|
+
for (const msg of transformed) addMsg(msg);
|
|
1913
|
+
} else {
|
|
1914
|
+
// Fallback path: matching tool_use not located (parent compressed it
|
|
1915
|
+
// away, or no callToolUseId). Wholesale copy is safe here because
|
|
1916
|
+
// the cascade-bait — the matching fork tool_use and its siblings —
|
|
1917
|
+
// got compressed away with the rest of the assistant turn. Append
|
|
1918
|
+
// a synthetic intention-framed result so the child still gets the
|
|
1919
|
+
// dual-stream framing as the salient last message.
|
|
1920
|
+
for (const msg of compiled) addMsg(msg);
|
|
1921
|
+
|
|
1922
|
+
const fallbackForkId = `fork-${input.name}-${crypto.randomUUID()}`;
|
|
1923
|
+
contextManager.addMessage(agentName, [{
|
|
1924
|
+
type: 'tool_use',
|
|
1925
|
+
id: fallbackForkId,
|
|
1926
|
+
name: 'subagent--fork',
|
|
1927
|
+
input: { name: input.name, task: input.task },
|
|
1928
|
+
}] as ContentBlock[]);
|
|
1929
|
+
contextManager.addMessage('user', [{
|
|
1930
|
+
type: 'tool_result',
|
|
1931
|
+
toolUseId: fallbackForkId,
|
|
1932
|
+
content: buildIntentionFramedForkResult(input.name, input.task, childDepth, this.maxDepth),
|
|
1933
|
+
}] as ContentBlock[]);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// Pre-validate prompt size
|
|
1938
|
+
const { messages } = await contextManager.compile();
|
|
1939
|
+
const tools = framework.getAllTools().filter(t => agent.canUseTool(t.name));
|
|
1940
|
+
const est = this.estimatePromptTokens(agent.systemPrompt, messages, tools);
|
|
1941
|
+
if (est > this.maxPromptTokens) {
|
|
1942
|
+
throw new Error(
|
|
1943
|
+
`Prompt too large for subagent ${input.name}: ~${est} tokens ` +
|
|
1944
|
+
`(limit: ${this.maxPromptTokens}). Reduce context or task size.`
|
|
1945
|
+
);
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// Race execution against both timeout and user cancellation
|
|
1949
|
+
const cancelPromise = new Promise<never>((_, reject) => {
|
|
1950
|
+
this.cancellationHandles.set(input.name, { reject });
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
let { speech, toolCallsCount } = await Promise.race([
|
|
1954
|
+
this.withTimeout(
|
|
1955
|
+
framework.runEphemeralToCompletion(agent, contextManager),
|
|
1956
|
+
input.name,
|
|
1957
|
+
executionTimeoutMs,
|
|
1958
|
+
),
|
|
1959
|
+
cancelPromise,
|
|
1960
|
+
]);
|
|
1961
|
+
|
|
1962
|
+
this.cancellationHandles.delete(input.name);
|
|
1963
|
+
|
|
1964
|
+
// Prefer explicit return over speech capture
|
|
1965
|
+
const returned = this.returnedResults.get(input.name);
|
|
1966
|
+
if (returned) {
|
|
1967
|
+
speech = returned;
|
|
1968
|
+
this.returnedResults.delete(input.name);
|
|
1969
|
+
} else if (!speech.trim()) {
|
|
1970
|
+
speech = this.extractLastAssistantText(contextManager);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
entry.status = 'completed';
|
|
1974
|
+
entry.completedAt = Date.now();
|
|
1975
|
+
entry.toolCallsCount = toolCallsCount;
|
|
1976
|
+
this.onSubagentSuccess();
|
|
1977
|
+
const notice = this.concurrencyNotice(waitedMs);
|
|
1978
|
+
const finalSummary = notice + speech;
|
|
1979
|
+
this.emit(input.name, { type: 'done', summary: finalSummary, lastInputTokens: this.lastInputTokens.get(input.name) });
|
|
1980
|
+
return { summary: finalSummary, findings: [], issues: [], toolCallsCount };
|
|
1981
|
+
} catch (err) {
|
|
1982
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1983
|
+
this.cancellationHandles.delete(input.name);
|
|
1984
|
+
|
|
1985
|
+
// Non-retryable termination (user cancel, zombie reclaim, etc.).
|
|
1986
|
+
// See P1 #4 comment in runSpawn — 'cancelled', not 'completed'.
|
|
1987
|
+
if (lastError instanceof SubagentTerminated) {
|
|
1988
|
+
entry.status = 'cancelled';
|
|
1989
|
+
entry.completedAt = Date.now();
|
|
1990
|
+
entry.statusMessage = lastError.reason;
|
|
1991
|
+
const notice = this.concurrencyNotice(waitedMs);
|
|
1992
|
+
const label = lastError.reason === 'cancelled' ? 'Stopped by user' : `Terminated: ${lastError.reason}`;
|
|
1993
|
+
const summary = notice + `[${label}] ` + (lastError.partialOutput || '(no output yet)');
|
|
1994
|
+
this.emit(input.name, { type: 'done', summary, lastInputTokens: this.lastInputTokens.get(input.name) });
|
|
1995
|
+
return { summary, findings: [], issues: [], toolCallsCount: entry.toolCallsCount };
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
if (this.isRateLimitError(lastError)) await this.onRateLimitHit();
|
|
1999
|
+
if (!this.isTransientError(lastError) || attempt === this.maxRetries) break;
|
|
2000
|
+
|
|
2001
|
+
const delay = Math.min(5_000 * (attempt + 1), 30_000);
|
|
2002
|
+
console.error(
|
|
2003
|
+
`[subagent] ${input.name} attempt ${attempt + 1}/${this.maxRetries + 1} failed: ` +
|
|
2004
|
+
`${lastError.message}. Restarting in ${delay}ms...`
|
|
2005
|
+
);
|
|
2006
|
+
entry.statusMessage = `Retry ${attempt + 1}: ${lastError.message}`;
|
|
2007
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
2008
|
+
} finally {
|
|
2009
|
+
this.agentDepths.delete(agentName);
|
|
2010
|
+
this.unregisterLive(input.name, agentName);
|
|
2011
|
+
cleanup();
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
entry.status = 'failed';
|
|
2016
|
+
entry.completedAt = Date.now();
|
|
2017
|
+
entry.statusMessage = lastError!.message;
|
|
2018
|
+
throw lastError!;
|
|
2019
|
+
} finally {
|
|
2020
|
+
this.persistState();
|
|
2021
|
+
this.releaseSlot();
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
/**
|
|
2026
|
+
* Extract the last assistant text from a context manager's messages.
|
|
2027
|
+
* Fallback when the streaming speech capture is empty (e.g., agent's
|
|
2028
|
+
* last action was a tool call, or speech was reset on stream_resumed).
|
|
2029
|
+
*/
|
|
2030
|
+
private extractLastAssistantText(contextManager: ContextManager): string {
|
|
2031
|
+
try {
|
|
2032
|
+
const messages = contextManager.getAllMessages();
|
|
2033
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2034
|
+
const msg = messages[i];
|
|
2035
|
+
if (msg.participant === 'user' || msg.participant === 'User') continue;
|
|
2036
|
+
const texts = msg.content
|
|
2037
|
+
.filter((b: ContentBlock) => b.type === 'text')
|
|
2038
|
+
.map((b: ContentBlock) => (b as { type: 'text'; text: string }).text);
|
|
2039
|
+
if (texts.length > 0) return texts.join('\n');
|
|
2040
|
+
}
|
|
2041
|
+
} catch {
|
|
2042
|
+
// best-effort
|
|
2043
|
+
}
|
|
2044
|
+
return '(no text output)';
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/**
|
|
2048
|
+
* Build the allowedTools list for a subagent.
|
|
2049
|
+
* Removes subagent tools if at depth limit.
|
|
2050
|
+
*
|
|
2051
|
+
*/
|
|
2052
|
+
private filterToolNames(allowedTools?: string[], callerDepth = 0): 'all' | string[] {
|
|
2053
|
+
// Always include subagent--return — subagents need it to deliver results
|
|
2054
|
+
const ensureReturn = (list: string[]) => {
|
|
2055
|
+
if (!list.includes('subagent--return')) list.push('subagent--return');
|
|
2056
|
+
return list;
|
|
2057
|
+
};
|
|
2058
|
+
|
|
2059
|
+
// Use per-agent depth (from caller) rather than the module's static depth
|
|
2060
|
+
if (callerDepth + 1 >= this.maxDepth) {
|
|
2061
|
+
const allTools = this.getFramework().getAllTools();
|
|
2062
|
+
const filtered = allTools
|
|
2063
|
+
.filter(t => !t.name.startsWith('subagent--'))
|
|
2064
|
+
.map(t => t.name);
|
|
2065
|
+
if (allowedTools) {
|
|
2066
|
+
const allowed = new Set(allowedTools);
|
|
2067
|
+
return ensureReturn(filtered.filter(n => allowed.has(n)));
|
|
2068
|
+
}
|
|
2069
|
+
return ensureReturn(filtered);
|
|
2070
|
+
}
|
|
2071
|
+
return allowedTools ? ensureReturn(allowedTools) : 'all';
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
}
|