@graphorin/agent 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +31 -11
  3. package/dist/errors/index.d.ts +17 -4
  4. package/dist/errors/index.d.ts.map +1 -1
  5. package/dist/errors/index.js +19 -3
  6. package/dist/errors/index.js.map +1 -1
  7. package/dist/factory.d.ts.map +1 -1
  8. package/dist/factory.js +153 -1930
  9. package/dist/factory.js.map +1 -1
  10. package/dist/fanout/index.d.ts +13 -1
  11. package/dist/fanout/index.d.ts.map +1 -1
  12. package/dist/fanout/index.js +13 -4
  13. package/dist/fanout/index.js.map +1 -1
  14. package/dist/index.d.ts +7 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +11 -9
  17. package/dist/index.js.map +1 -1
  18. package/dist/lateral-leak/index.js +1 -1
  19. package/dist/package.js +6 -0
  20. package/dist/package.js.map +1 -0
  21. package/dist/run-state/index.d.ts +32 -6
  22. package/dist/run-state/index.d.ts.map +1 -1
  23. package/dist/run-state/index.js +46 -22
  24. package/dist/run-state/index.js.map +1 -1
  25. package/dist/runtime/agent-surface.js +122 -0
  26. package/dist/runtime/agent-surface.js.map +1 -0
  27. package/dist/runtime/agent-to-tool.d.ts +51 -0
  28. package/dist/runtime/agent-to-tool.d.ts.map +1 -0
  29. package/dist/runtime/agent-to-tool.js +145 -0
  30. package/dist/runtime/agent-to-tool.js.map +1 -0
  31. package/dist/runtime/approvals.js +0 -0
  32. package/dist/runtime/approvals.js.map +1 -0
  33. package/dist/runtime/dispatch.js +108 -0
  34. package/dist/runtime/dispatch.js.map +1 -0
  35. package/dist/runtime/executor-wiring.js +128 -0
  36. package/dist/runtime/executor-wiring.js.map +1 -0
  37. package/dist/runtime/fallback-chain.js +139 -0
  38. package/dist/runtime/fallback-chain.js.map +1 -0
  39. package/dist/runtime/handoff.js +307 -0
  40. package/dist/runtime/handoff.js.map +1 -0
  41. package/dist/runtime/messages.d.ts +22 -0
  42. package/dist/runtime/messages.d.ts.map +1 -0
  43. package/dist/runtime/messages.js +204 -0
  44. package/dist/runtime/messages.js.map +1 -0
  45. package/dist/runtime/provider-events.js +117 -0
  46. package/dist/runtime/provider-events.js.map +1 -0
  47. package/dist/runtime/run-compaction.js +210 -0
  48. package/dist/runtime/run-compaction.js.map +1 -0
  49. package/dist/runtime/run-finish.js +48 -0
  50. package/dist/runtime/run-finish.js.map +1 -0
  51. package/dist/runtime/run-gates.js +336 -0
  52. package/dist/runtime/run-gates.js.map +1 -0
  53. package/dist/runtime/run-init.js +81 -0
  54. package/dist/runtime/run-init.js.map +1 -0
  55. package/dist/runtime/run-input.js +46 -0
  56. package/dist/runtime/run-input.js.map +1 -0
  57. package/dist/runtime/step-catalogue.js +173 -0
  58. package/dist/runtime/step-catalogue.js.map +1 -0
  59. package/dist/runtime/tool-call-walk.js +189 -0
  60. package/dist/runtime/tool-call-walk.js.map +1 -0
  61. package/dist/runtime/tool-wiring.js +159 -0
  62. package/dist/runtime/tool-wiring.js.map +1 -0
  63. package/dist/tooling/adapters.js +1 -1
  64. package/dist/tooling/dataflow.js +1 -1
  65. package/dist/tooling/policy.js +2 -0
  66. package/dist/tooling/policy.js.map +1 -1
  67. package/dist/types.d.ts +63 -13
  68. package/dist/types.d.ts.map +1 -1
  69. package/package.json +20 -20
  70. package/src/errors/index.ts +320 -0
  71. package/src/evaluator-optimizer/index.ts +212 -0
  72. package/src/factory.ts +957 -0
  73. package/src/fallback/index.ts +108 -0
  74. package/src/fanout/index.ts +523 -0
  75. package/src/filters/index.ts +347 -0
  76. package/src/index.ts +180 -0
  77. package/src/internal/ids.ts +46 -0
  78. package/src/internal/usage-accumulator.ts +90 -0
  79. package/src/lateral-leak/causality-monitor.ts +221 -0
  80. package/src/lateral-leak/index.ts +35 -0
  81. package/src/lateral-leak/merge-guard.ts +151 -0
  82. package/src/lateral-leak/protocol-guard.ts +222 -0
  83. package/src/preferred-model/index.ts +210 -0
  84. package/src/progress/index.ts +238 -0
  85. package/src/run-state/index.ts +607 -0
  86. package/src/runtime/agent-surface.ts +218 -0
  87. package/src/runtime/agent-to-tool.ts +323 -0
  88. package/src/runtime/approvals.ts +0 -0
  89. package/src/runtime/dispatch.ts +183 -0
  90. package/src/runtime/executor-wiring.ts +331 -0
  91. package/src/runtime/fallback-chain.ts +250 -0
  92. package/src/runtime/handoff.ts +428 -0
  93. package/src/runtime/messages.ts +309 -0
  94. package/src/runtime/provider-events.ts +175 -0
  95. package/src/runtime/run-compaction.ts +288 -0
  96. package/src/runtime/run-finish.ts +93 -0
  97. package/src/runtime/run-gates.ts +419 -0
  98. package/src/runtime/run-init.ts +169 -0
  99. package/src/runtime/run-input.ts +102 -0
  100. package/src/runtime/step-catalogue.ts +338 -0
  101. package/src/runtime/tool-call-walk.ts +301 -0
  102. package/src/runtime/tool-wiring.ts +218 -0
  103. package/src/testing/replay-provider.ts +121 -0
  104. package/src/tooling/adapters.ts +403 -0
  105. package/src/tooling/catalogue.ts +36 -0
  106. package/src/tooling/dataflow.ts +171 -0
  107. package/src/tooling/plan.ts +123 -0
  108. package/src/tooling/policy.ts +67 -0
  109. package/src/tooling/registry-build.ts +191 -0
  110. package/src/types.ts +696 -0
@@ -0,0 +1,250 @@
1
+ /**
2
+ * The per-step provider fallback chain of the agent run loop: streams
3
+ * the primary provider (emitting the accumulated agent events), applies
4
+ * the AG-6 abort semantics, retries a hard context overflow once after
5
+ * an emergency compaction (context-engine-06), walks the configured
6
+ * fallback providers on eligible errors (emitting
7
+ * `agent.model.fellback`), and records a terminal provider failure on
8
+ * the run state. Extracted verbatim from `factory.ts` (issue #23); the
9
+ * former in-loop chain now takes an explicit {@link FallbackChainEnv}
10
+ * and returns a {@link ProviderChainOutcome} instead of finishing the
11
+ * run itself (`failed: true` tells the loop to finish).
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+
16
+ import type {
17
+ AgentEvent,
18
+ Message,
19
+ Provider,
20
+ ProviderError,
21
+ ProviderRequest,
22
+ ReasoningContent,
23
+ RunState,
24
+ ToolCall,
25
+ Usage,
26
+ } from '@graphorin/core';
27
+ import { zeroUsage } from '@graphorin/core';
28
+ import { AgentRuntimeError } from '../errors/index.js';
29
+ import { type AgentFallbackPolicy, isAgentFallbackEligible } from '../fallback/index.js';
30
+ import type { PreferredModelResolution } from '../preferred-model/index.js';
31
+ import type { AbortOptions } from '../types.js';
32
+ import { accumulateUsage, addUsage, buildStepMessages } from './messages.js';
33
+ import {
34
+ classifyThrownProviderErrorKind,
35
+ handleProviderEvent,
36
+ type ProviderEventCollector,
37
+ type ToolCallAccumulator,
38
+ } from './provider-events.js';
39
+ import type { MutableRunState } from './run-input.js';
40
+
41
+ /**
42
+ * The run-scoped context the chain operates on. Field names mirror the
43
+ * run-loop locals the former inline chain captured; `getPendingAbort` /
44
+ * `getActiveTodos` are live reads of the factory's mutable scratch, and
45
+ * `tryEmergencyCompact` is pre-bound to the run's compaction env.
46
+ */
47
+ export interface FallbackChainEnv<TOutput> {
48
+ readonly state: MutableRunState & RunState;
49
+ readonly messages: Message[];
50
+ readonly sessionId: string;
51
+ readonly agentId: string;
52
+ readonly signal: AbortSignal;
53
+ readonly fallbackPolicy: AgentFallbackPolicy;
54
+ readonly structuredInstruction: string | undefined;
55
+ readonly getPendingAbort: () => AbortOptions | undefined;
56
+ readonly getActiveTodos: () => ReadonlyArray<import('@graphorin/core').TodoItem> | undefined;
57
+ readonly tryEmergencyCompact: () => AsyncGenerator<AgentEvent<TOutput>, boolean, void>;
58
+ }
59
+
60
+ /** What one step's provider chain resolved to (read by the run loop). */
61
+ export interface ProviderChainOutcome {
62
+ /**
63
+ * `true` when the chain recorded a terminal provider failure on the
64
+ * run state (the `agent.error` event already streamed) - the loop
65
+ * must finish the run.
66
+ */
67
+ readonly failed: boolean;
68
+ readonly modelSucceeded: boolean;
69
+ readonly textBuffer: string;
70
+ readonly finalCalls: ToolCall[];
71
+ readonly stepReasoningParts: ReasoningContent[];
72
+ readonly stepUsage: Usage;
73
+ readonly lastModelId: string;
74
+ }
75
+
76
+ export async function* runProviderFallbackChain<TOutput>(
77
+ env: FallbackChainEnv<TOutput>,
78
+ fallbackChain: Provider[],
79
+ baseRequest: ProviderRequest,
80
+ primary: PreferredModelResolution,
81
+ stepNumber: number,
82
+ ): AsyncGenerator<AgentEvent<TOutput>, ProviderChainOutcome, void> {
83
+ const { state, messages, sessionId, agentId, signal, fallbackPolicy } = env;
84
+ const { structuredInstruction, getPendingAbort, getActiveTodos, tryEmergencyCompact } = env;
85
+
86
+ const stepUsage: Usage = zeroUsage();
87
+ let attempt = 0;
88
+ let textBuffer = '';
89
+ let providerForStep = primary.resolvedProvider;
90
+ let lastModelId = primary.resolvedModelId;
91
+ let modelSucceeded = false;
92
+ let lastError: ProviderError | undefined;
93
+ let finalCalls: ToolCall[] = [];
94
+ let stepReasoningParts: ReasoningContent[] = [];
95
+ // context-engine-06: the request actually sent - rebuilt after an
96
+ // emergency compaction so the retry carries the shrunk buffer
97
+ // even on the structured-output path (which snapshots messages).
98
+ let requestForStep = baseRequest;
99
+ let emergencyCompactTried = false;
100
+
101
+ for (let chainIdx = 0; chainIdx < fallbackChain.length; chainIdx++) {
102
+ const candidate = fallbackChain[chainIdx];
103
+ if (candidate === undefined) continue;
104
+ providerForStep = candidate;
105
+ const providerModelId = providerForStep.modelId;
106
+ if (chainIdx > 0) {
107
+ attempt += 1;
108
+ const reason = lastError
109
+ ? (isAgentFallbackEligible(lastError, fallbackPolicy).reason ?? 'transient')
110
+ : 'transient';
111
+ yield {
112
+ type: 'agent.model.fellback',
113
+ runId: state.id,
114
+ sessionId,
115
+ agentId,
116
+ from: lastModelId,
117
+ to: providerModelId,
118
+ reason,
119
+ stepNumber,
120
+ attempt,
121
+ };
122
+ lastModelId = providerModelId;
123
+ }
124
+ const evState: ProviderEventCollector = {
125
+ textBuffer: '',
126
+ reasoningBuffer: '',
127
+ reasoningParts: [],
128
+ calls: new Map<string, ToolCallAccumulator>(),
129
+ finalCalls: [] as ToolCall[],
130
+ };
131
+ let providerError: ProviderError | undefined;
132
+ let providerCallCompleted = false;
133
+ let providerStepUsage: Usage = zeroUsage();
134
+ try {
135
+ const stream = providerForStep.stream(requestForStep);
136
+ for await (const ev of stream) {
137
+ // AG-6 `drain`: the default hard-kills the in-flight provider
138
+ // stream mid-event; `abort({ drain: true })` instead lets the
139
+ // current step finish (the documented "wait for the current step
140
+ // to complete") and stops gracefully at the next loop-top check.
141
+ if (signal.aborted && getPendingAbort()?.drain !== true) {
142
+ throw new AgentRuntimeError('run-aborted', 'aborted');
143
+ }
144
+ const out = handleProviderEvent(ev, evState);
145
+ if (out.emit !== undefined) {
146
+ yield out.emit as AgentEvent<TOutput>;
147
+ }
148
+ if (out.providerError !== undefined) {
149
+ providerError = out.providerError;
150
+ }
151
+ if (out.usage !== undefined) {
152
+ providerStepUsage = addUsage(providerStepUsage, out.usage);
153
+ }
154
+ if (out.finished === true) providerCallCompleted = true;
155
+ }
156
+ } catch (cause) {
157
+ // AG-6: a mid-stream abort (our run-aborted sentinel, or any error
158
+ // once the signal is aborted - e.g. a native AbortError from the
159
+ // provider) is NOT a provider failure. Break out of the fallback
160
+ // chain WITHOUT a providerError; the post-stream abort check below
161
+ // ends the run as 'aborted', never 'no-provider-completed'. Don't
162
+ // continue the fallback chain against an already-aborted signal.
163
+ if (signal.aborted || (cause instanceof AgentRuntimeError && cause.code === 'run-aborted')) {
164
+ break;
165
+ }
166
+ const message = cause instanceof Error ? cause.message : String(cause);
167
+ // AG-21: preserve the thrown error's kind (e.g. a RateLimitExceededError
168
+ // from `withRateLimit`) so the fallback chain treats it like the same
169
+ // error emitted as a structured event, instead of flattening it to
170
+ // an always-ineligible 'unknown'.
171
+ providerError = { kind: classifyThrownProviderErrorKind(cause), message, cause };
172
+ }
173
+ if (providerError !== undefined) {
174
+ lastError = providerError;
175
+ // context-engine-06: a hard context overflow gets ONE
176
+ // emergency-compaction retry against the SAME candidate
177
+ // before the fallback chain (whose members usually share the
178
+ // window) or a terminal failure.
179
+ if (providerError.kind === 'context-length' && !emergencyCompactTried) {
180
+ emergencyCompactTried = true;
181
+ const shrank = yield* tryEmergencyCompact();
182
+ if (shrank) {
183
+ requestForStep = {
184
+ ...baseRequest,
185
+ messages: buildStepMessages(messages, structuredInstruction, getActiveTodos()),
186
+ };
187
+ chainIdx -= 1; // negate the loop increment: retry this candidate
188
+ continue;
189
+ }
190
+ }
191
+ const eligibility = isAgentFallbackEligible(providerError, fallbackPolicy);
192
+ if (!eligibility.eligible || chainIdx === fallbackChain.length - 1) {
193
+ yield {
194
+ type: 'agent.error',
195
+ error: { message: providerError.message, code: providerError.kind },
196
+ };
197
+ state.status = 'failed';
198
+ state.error = { message: providerError.message, code: providerError.kind };
199
+ return {
200
+ failed: true,
201
+ modelSucceeded,
202
+ textBuffer,
203
+ finalCalls,
204
+ stepReasoningParts,
205
+ stepUsage,
206
+ lastModelId,
207
+ };
208
+ }
209
+ continue;
210
+ }
211
+ if (providerCallCompleted) {
212
+ modelSucceeded = true;
213
+ textBuffer = evState.textBuffer;
214
+ finalCalls = evState.finalCalls;
215
+ // W-024: adapters with per-block structure ('reasoning-end'
216
+ // terminators) produce reasoningParts carrying the provider's
217
+ // round-trip meta (thinking signatures) - use them as-is, plus a
218
+ // meta-less tail for any deltas after the last terminator. The
219
+ // single-part collapse remains the fallback for adapters without
220
+ // block structure (ollama, openai-shaped, llamacpp).
221
+ if (evState.reasoningParts.length > 0) {
222
+ stepReasoningParts = [
223
+ ...evState.reasoningParts,
224
+ ...(evState.reasoningBuffer.length > 0
225
+ ? [{ type: 'reasoning' as const, text: evState.reasoningBuffer }]
226
+ : []),
227
+ ];
228
+ } else if (evState.reasoningBuffer.length > 0) {
229
+ stepReasoningParts = [
230
+ {
231
+ type: 'reasoning',
232
+ text: evState.reasoningBuffer,
233
+ },
234
+ ];
235
+ }
236
+ accumulateUsage(stepUsage, providerStepUsage);
237
+ break;
238
+ }
239
+ }
240
+
241
+ return {
242
+ failed: false,
243
+ modelSucceeded,
244
+ textBuffer,
245
+ finalCalls,
246
+ stepReasoningParts,
247
+ stepUsage,
248
+ lastModelId,
249
+ };
250
+ }
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Multi-agent handoff support for the agent runtime: the synthetic
3
+ * `transfer_to_<name>` tool advertised per step, the `DescribedFilter`
4
+ * structural check, and the inline execution of a handoff call (the
5
+ * sub-agent stream observation that surfaces a failed / aborted sub-run
6
+ * as a tool error). Extracted verbatim from `factory.ts` (issue #23).
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ import type {
12
+ AgentEvent,
13
+ AgentResult,
14
+ CompletedToolCall,
15
+ HandoffRecord,
16
+ Message,
17
+ RunState,
18
+ Tool,
19
+ ToolCall,
20
+ ToolError,
21
+ UsageAccumulator,
22
+ } from '@graphorin/core';
23
+ import { type DescribedFilter, filters as filterLib } from '../filters/index.js';
24
+ import type { Agent, AgentCallOptions, AgentConfig, SubagentForwardPolicy } from '../types.js';
25
+ import type { SubAgentFoldTaint } from './agent-to-tool.js';
26
+ import { foldChildRunUsage, renderToolErrorMessage } from './messages.js';
27
+ import type { MutableRunState } from './run-input.js';
28
+
29
+ export const HANDOFF_TOOL_PREFIX = 'transfer_to_';
30
+
31
+ export const PASSTHROUGH_SCHEMA = {
32
+ parse: <T>(value: unknown): T => value as T,
33
+ safeParse: <T>(value: unknown) => ({ success: true as const, data: value as T }),
34
+ toJSON: (): Record<string, unknown> => ({ type: 'object' }),
35
+ } as const;
36
+
37
+ export function isDescribedFilter(value: unknown): value is DescribedFilter {
38
+ return (
39
+ typeof value === 'function' &&
40
+ 'descriptor' in value &&
41
+ typeof (value as DescribedFilter).descriptor === 'object'
42
+ );
43
+ }
44
+
45
+ export function buildHandoffTool<TDeps>(
46
+ target: Agent<TDeps, unknown>,
47
+ ): Tool<unknown, unknown, TDeps> {
48
+ const cfg = target.config;
49
+ const name = `${HANDOFF_TOOL_PREFIX}${cfg.name}`;
50
+ const tool: Tool<unknown, unknown, TDeps> = {
51
+ name,
52
+ description: `Hand off control to agent '${cfg.name}'.`,
53
+ inputSchema: PASSTHROUGH_SCHEMA as unknown as Tool<unknown, unknown, TDeps>['inputSchema'],
54
+ sideEffectClass: 'pure',
55
+ async execute(): Promise<string> {
56
+ return `[handoff: ${cfg.name}]`;
57
+ },
58
+ };
59
+ return tool;
60
+ }
61
+
62
+ /**
63
+ * A handoff child's seed must be a well-formed transcript in its own
64
+ * right: the parent history it is cut from ends with the in-flight
65
+ * handoff call itself (a dangling tool_use), and `lastN`-style filters
66
+ * can orphan tool results whose assistant partner was cut. Real
67
+ * providers reject both shapes with `invalid-request`, so strip
68
+ * unresolved tool calls (dropping the assistant message when nothing
69
+ * remains) and orphan tool messages before seeding the child.
70
+ */
71
+ export function sanitizeHandoffSeed(messages: ReadonlyArray<Message>): Message[] {
72
+ const announcedSoFar = new Set<string>();
73
+ const resolved = new Set<string>();
74
+ for (const m of messages) {
75
+ if (m.role === 'assistant' && m.toolCalls !== undefined) {
76
+ for (const c of m.toolCalls) announcedSoFar.add(c.toolCallId);
77
+ } else if (m.role === 'tool' && announcedSoFar.has(m.toolCallId)) {
78
+ resolved.add(m.toolCallId);
79
+ }
80
+ }
81
+ const out: Message[] = [];
82
+ const keptAnnounced = new Set<string>();
83
+ for (const m of messages) {
84
+ if (m.role === 'tool') {
85
+ if (!keptAnnounced.has(m.toolCallId)) continue;
86
+ out.push(m);
87
+ continue;
88
+ }
89
+ if (m.role === 'assistant' && m.toolCalls !== undefined) {
90
+ const kept = m.toolCalls.filter((c) => resolved.has(c.toolCallId));
91
+ for (const c of kept) keptAnnounced.add(c.toolCallId);
92
+ if (kept.length === m.toolCalls.length) {
93
+ out.push(m);
94
+ continue;
95
+ }
96
+ const { toolCalls: _dropped, ...rest } = m;
97
+ const hasContent =
98
+ typeof rest.content === 'string'
99
+ ? rest.content.length > 0
100
+ : Array.isArray(rest.content) && rest.content.length > 0;
101
+ if (kept.length === 0 && !hasContent) continue;
102
+ out.push(kept.length > 0 ? { ...rest, toolCalls: kept } : rest);
103
+ continue;
104
+ }
105
+ out.push(m);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /** One resolved handoff target (the factory's `handoffMap` values). */
111
+ export interface HandoffEntry<TDeps> {
112
+ readonly agent: Agent<TDeps, unknown>;
113
+ readonly filter: DescribedFilter | undefined;
114
+ /** W-036: which child events forward into the parent stream. */
115
+ readonly forwardEvents?: SubagentForwardPolicy | undefined;
116
+ }
117
+
118
+ /** The run-scoped context a handoff execution operates on. */
119
+ export interface HandoffRunEnv<TDeps, TOutput> {
120
+ readonly config: Pick<AgentConfig<TDeps, TOutput>, 'deps'>;
121
+ readonly options: AgentCallOptions<TDeps>;
122
+ readonly state: MutableRunState & RunState;
123
+ readonly messages: Message[];
124
+ readonly sessionId: string;
125
+ readonly agentId: string;
126
+ readonly signal: AbortSignal;
127
+ /** The run's usage accumulator - child-run usage folds into it (W-033). */
128
+ readonly usageAcc: UsageAccumulator;
129
+ /**
130
+ * W-036: live read of the parent's current step span, the parent for
131
+ * a sub-agent's `agent.run` span (one trace tree).
132
+ */
133
+ readonly getCurrentStepSpan?: () => import('@graphorin/core').AISpan | undefined;
134
+ }
135
+
136
+ /**
137
+ * Execute one handoff tool call inline (handoffs are special-cased by
138
+ * the tool-call walk, ≤1 per step, and never routed through the
139
+ * executor): record the {@link HandoffRecord}, transfer
140
+ * `currentAgentId`, stream the sub-agent over the filtered history, and
141
+ * surface its outcome as the long-standing `tool.execute.*` events +
142
+ * tool message.
143
+ */
144
+ export async function* executeHandoffToolCall<TDeps, TOutput>(
145
+ env: HandoffRunEnv<TDeps, TOutput>,
146
+ call: ToolCall,
147
+ handoff: HandoffEntry<TDeps>,
148
+ stepNumber: number,
149
+ ): AsyncGenerator<AgentEvent<TOutput>, { readonly suspendRequested: boolean }, void> {
150
+ const { config, options, state, messages, sessionId, agentId, signal } = env;
151
+ yield { type: 'tool.execute.start', toolCallId: call.toolCallId, toolName: call.toolName };
152
+ const filter = (handoff.filter ?? filterLib.defaultHandoffFilter()) as DescribedFilter;
153
+ const filtered = sanitizeHandoffSeed(filter(messages) as Message[]);
154
+ const targetId = handoff.agent.id;
155
+ // The secrets fields record the structural reality: no
156
+ // inheritance mechanism exists at this boundary, so the
157
+ // target receives nothing - an empty allowlist is the
158
+ // factually-true provenance (AG-17).
159
+ const handoffRec: HandoffRecord = {
160
+ fromAgentId: agentId,
161
+ toAgentId: targetId,
162
+ stepNumber,
163
+ at: new Date().toISOString(),
164
+ inputFilter: filter.descriptor,
165
+ secretsInheritance: 'inherit-allowlist',
166
+ inheritedSecrets: [],
167
+ };
168
+ state.handoffs.push(handoffRec);
169
+ yield { type: 'handoff', fromAgentId: agentId, toAgentId: targetId };
170
+ // W-034: `currentAgentId` identifies the agent whose model drives the
171
+ // NEXT step - the parent resumes driving once the child returns, so
172
+ // the transfer is scoped to the child observation and restored in
173
+ // `finally` (every branch, including the W-001 park, and on generator
174
+ // teardown). The child's identity is durably recorded by the
175
+ // HandoffRecord + handoff event.
176
+ const previousAgentId = state.currentAgentId;
177
+ state.currentAgentId = targetId;
178
+ const subAgent = handoff.agent;
179
+ try {
180
+ // AG-22: the sub-agent inherits the parent's abort signal,
181
+ // deps, and sessionId; its terminal `agent.end` is observed
182
+ // so a failed/aborted sub-run surfaces as a TOOL ERROR -
183
+ // never an empty-string success with durationMs 0.
184
+ const parentSpan = env.getCurrentStepSpan?.();
185
+ const subStream = subAgent.stream(filtered as Message[], {
186
+ signal,
187
+ ...(options.deps !== undefined || config.deps !== undefined
188
+ ? { deps: (options.deps ?? config.deps) as TDeps }
189
+ : {}),
190
+ sessionId,
191
+ // W-036: one trace tree - the child's run span parents here.
192
+ ...(parentSpan !== undefined ? { parentSpan } : {}),
193
+ });
194
+ return yield* runSubAgentCall<TDeps, TOutput>(
195
+ env,
196
+ call,
197
+ {
198
+ agentName: handoff.agent.config.name,
199
+ subStream: subStream as AsyncIterable<AgentEvent<unknown>>,
200
+ errorLabel: `handoff to '${targetId}'`,
201
+ renderCompleted: (_subResult, turns) => ({ output: turns.join('') }),
202
+ ...(handoff.forwardEvents !== undefined ? { forwardEvents: handoff.forwardEvents } : {}),
203
+ },
204
+ stepNumber,
205
+ );
206
+ } finally {
207
+ state.currentAgentId = previousAgentId;
208
+ }
209
+ }
210
+
211
+ /** Branch-specific spec for the shared sub-run seam (W-001). */
212
+ export interface SubRunSpec {
213
+ /** The child agent's configured name (parking + usage folding). */
214
+ readonly agentName: string;
215
+ readonly subStream: AsyncIterable<AgentEvent<unknown>>;
216
+ /** Prefix for the failed/aborted tool-error message. */
217
+ readonly errorLabel: string;
218
+ /** Shape a completed child into the tool-message payload. */
219
+ readonly renderCompleted: (
220
+ subResult: AgentResult<unknown>,
221
+ turns: ReadonlyArray<string>,
222
+ ) => { readonly output: unknown; readonly taint?: SubAgentFoldTaint };
223
+ /** Optional taint sink (the inline toTool path records D2 folds). */
224
+ readonly recordTaint?: (taint: SubAgentFoldTaint, renderedText: string) => void;
225
+ /** W-036: forwarding policy (default `'lifecycle'`). */
226
+ readonly forwardEvents?: SubagentForwardPolicy;
227
+ }
228
+
229
+ /**
230
+ * W-036: the `'lifecycle'` forwarding whitelist - load-bearing child
231
+ * events, never the high-frequency text/reasoning deltas.
232
+ */
233
+ const LIFECYCLE_FORWARD_TYPES: ReadonlySet<string> = new Set([
234
+ 'tool.execute.start',
235
+ 'tool.execute.progress',
236
+ 'tool.execute.partial',
237
+ 'tool.execute.end',
238
+ 'tool.execute.error',
239
+ 'tool.approval.requested',
240
+ 'tool.approval.granted',
241
+ 'tool.approval.denied',
242
+ 'guardrail.tripped',
243
+ 'agent.lateral-leak.detected',
244
+ 'context.compacted',
245
+ 'agent.error',
246
+ ]);
247
+
248
+ function shouldForwardSubagentEvent(policy: SubagentForwardPolicy, eventType: string): boolean {
249
+ if (policy === 'none') return false;
250
+ if (policy === 'all') return true;
251
+ return LIFECYCLE_FORWARD_TYPES.has(eventType);
252
+ }
253
+
254
+ /**
255
+ * W-001: compose the sub-run routing path. An approval mirrored from a
256
+ * child that itself parked a grandchild already carries the CHILD-level
257
+ * routing; prefixing this level's park key keeps the full path intact
258
+ * (`<parentCallId>/<childCallId>/...`), so each resume level strips one
259
+ * segment and routes the remainder down. Park-key toolCallIds must not
260
+ * contain `/` (provider call ids do not).
261
+ */
262
+ export function composeSubRunPath(parkKey: string, nested: string | undefined): string {
263
+ return nested === undefined ? parkKey : `${parkKey}/${nested}`;
264
+ }
265
+
266
+ /** Split one routing segment off a composed sub-run path (W-001). */
267
+ export function splitSubRunPath(path: string): {
268
+ readonly head: string;
269
+ readonly rest: string | undefined;
270
+ } {
271
+ const idx = path.indexOf('/');
272
+ if (idx === -1) return { head: path, rest: undefined };
273
+ return { head: path.slice(0, idx), rest: path.slice(idx + 1) };
274
+ }
275
+
276
+ /** Park (or refresh) a suspended child on the parent state (W-001). */
277
+ function parkSubRun(
278
+ state: MutableRunState & RunState,
279
+ call: ToolCall,
280
+ agentName: string,
281
+ childState: RunState,
282
+ ): void {
283
+ const entry = {
284
+ toolCallId: call.toolCallId,
285
+ toolName: call.toolName,
286
+ targetAgentName: agentName,
287
+ state: childState,
288
+ };
289
+ const subs = state.pendingSubRuns;
290
+ if (subs === undefined) {
291
+ state.pendingSubRuns = [entry];
292
+ return;
293
+ }
294
+ const idx = subs.findIndex((s) => s.toolCallId === call.toolCallId);
295
+ if (idx === -1) subs.push(entry);
296
+ else subs[idx] = entry;
297
+ }
298
+
299
+ /**
300
+ * W-001: the shared sub-run seam behind handoff and inline `toTool`
301
+ * execution. Observes the child stream and settles the outcome:
302
+ *
303
+ * - `awaiting_approval`: the child PARKS on `state.pendingSubRuns`, its
304
+ * pending approvals mirror onto the parent's `pendingApprovals` with
305
+ * `subRunToolCallId` set, and the walk suspends the parent once per
306
+ * step. The parked toolCallId keeps NO tool message - exactly like a
307
+ * directly-gated call - and the resume guard keeps it out of the
308
+ * provider loop.
309
+ * - terminal failure: a typed tool error (the pre-W-001 behavior).
310
+ * - completed: the branch-shaped output becomes the tool message.
311
+ *
312
+ * Usage folds on terminal outcomes only - the child's cumulative usage
313
+ * folds exactly once when it finally completes or fails, never at a
314
+ * park (a park would double-count the pre-suspend tokens on resume).
315
+ */
316
+ export async function* runSubAgentCall<TDeps, TOutput>(
317
+ env: HandoffRunEnv<TDeps, TOutput>,
318
+ call: ToolCall,
319
+ spec: SubRunSpec,
320
+ stepNumber: number,
321
+ ): AsyncGenerator<AgentEvent<TOutput>, { readonly suspendRequested: boolean }, void> {
322
+ const { state, messages, usageAcc } = env;
323
+ const subStart = Date.now();
324
+ const turns: string[] = [];
325
+ const forwardPolicy = spec.forwardEvents ?? 'lifecycle';
326
+ let subResult: AgentResult<unknown> | undefined;
327
+ for await (const subEv of spec.subStream) {
328
+ if (subEv.type === 'text.complete') turns.push(subEv.text);
329
+ else if (subEv.type === 'agent.end') {
330
+ subResult = subEv.result as AgentResult<unknown>;
331
+ }
332
+ // W-036: surface the child's load-bearing events in the parent
333
+ // stream, wrapped so they never alias the parent's own lifecycle.
334
+ if (shouldForwardSubagentEvent(forwardPolicy, subEv.type)) {
335
+ yield {
336
+ type: 'subagent.event',
337
+ toolCallId: call.toolCallId,
338
+ agentName: spec.agentName,
339
+ event: subEv as AgentEvent<unknown>,
340
+ } as AgentEvent<TOutput>;
341
+ }
342
+ }
343
+ const subDurationMs = Date.now() - subStart;
344
+
345
+ if (subResult !== undefined && subResult.status === 'awaiting_approval') {
346
+ parkSubRun(state, call, spec.agentName, subResult.state);
347
+ for (const approval of subResult.state.pendingApprovals) {
348
+ state.pendingApprovals.push({
349
+ ...approval,
350
+ subRunToolCallId: composeSubRunPath(call.toolCallId, approval.subRunToolCallId),
351
+ });
352
+ yield {
353
+ type: 'tool.approval.requested',
354
+ toolCallId: approval.toolCallId,
355
+ ...(approval.reason !== undefined ? { reason: approval.reason } : {}),
356
+ };
357
+ }
358
+ return { suspendRequested: true };
359
+ }
360
+
361
+ // W-033: fold the child's usage into the parent's accounting on every
362
+ // TERMINAL outcome - tokens were spent whether it completed or failed.
363
+ if (subResult !== undefined) {
364
+ foldChildRunUsage(state, usageAcc, subResult.state, spec.agentName);
365
+ }
366
+ const stepEntry = state.steps[state.steps.length - 1];
367
+ if (subResult !== undefined && subResult.status !== 'completed') {
368
+ const toolError: ToolError = {
369
+ toolCallId: call.toolCallId,
370
+ toolName: call.toolName,
371
+ kind: subResult.status === 'aborted' ? 'aborted' : 'execution_failed',
372
+ message: `${spec.errorLabel} ${subResult.status}${
373
+ subResult.error !== undefined ? `: ${subResult.error.message}` : ''
374
+ }`,
375
+ };
376
+ if (stepEntry !== undefined) {
377
+ (stepEntry.toolCalls as CompletedToolCall[]).push({
378
+ call,
379
+ outcome: toolError,
380
+ stepNumber,
381
+ });
382
+ }
383
+ yield {
384
+ type: 'tool.execute.error',
385
+ toolCallId: call.toolCallId,
386
+ toolName: call.toolName,
387
+ error: toolError,
388
+ };
389
+ const text = renderToolErrorMessage(toolError);
390
+ messages.push({ role: 'tool', toolCallId: call.toolCallId, content: text });
391
+ state.messages.push({ role: 'tool', toolCallId: call.toolCallId, content: text });
392
+ return { suspendRequested: false };
393
+ }
394
+ const shaped =
395
+ subResult !== undefined ? spec.renderCompleted(subResult, turns) : { output: turns.join('') };
396
+ const result =
397
+ typeof shaped.output === 'string' ? shaped.output : (JSON.stringify(shaped.output) ?? '');
398
+ if ('taint' in shaped && shaped.taint !== undefined) {
399
+ spec.recordTaint?.(shaped.taint, result);
400
+ }
401
+ const completed: CompletedToolCall = {
402
+ call,
403
+ outcome: {
404
+ toolCallId: call.toolCallId,
405
+ toolName: call.toolName,
406
+ output: result,
407
+ durationMs: subDurationMs,
408
+ },
409
+ stepNumber,
410
+ };
411
+ if (stepEntry !== undefined) {
412
+ (stepEntry.toolCalls as CompletedToolCall[]).push(completed);
413
+ }
414
+ yield {
415
+ type: 'tool.execute.end',
416
+ toolCallId: call.toolCallId,
417
+ toolName: call.toolName,
418
+ result,
419
+ durationMs: subDurationMs,
420
+ };
421
+ messages.push({ role: 'tool', toolCallId: call.toolCallId, content: result });
422
+ state.messages.push({
423
+ role: 'tool',
424
+ toolCallId: call.toolCallId,
425
+ content: result,
426
+ });
427
+ return { suspendRequested: false };
428
+ }