@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.
- package/CHANGELOG.md +96 -0
- package/README.md +31 -11
- package/dist/errors/index.d.ts +17 -4
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/errors/index.js +19 -3
- package/dist/errors/index.js.map +1 -1
- package/dist/factory.d.ts.map +1 -1
- package/dist/factory.js +153 -1930
- package/dist/factory.js.map +1 -1
- package/dist/fanout/index.d.ts +13 -1
- package/dist/fanout/index.d.ts.map +1 -1
- package/dist/fanout/index.js +13 -4
- package/dist/fanout/index.js.map +1 -1
- package/dist/index.d.ts +7 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -9
- package/dist/index.js.map +1 -1
- package/dist/lateral-leak/index.js +1 -1
- package/dist/package.js +6 -0
- package/dist/package.js.map +1 -0
- package/dist/run-state/index.d.ts +32 -6
- package/dist/run-state/index.d.ts.map +1 -1
- package/dist/run-state/index.js +46 -22
- package/dist/run-state/index.js.map +1 -1
- package/dist/runtime/agent-surface.js +122 -0
- package/dist/runtime/agent-surface.js.map +1 -0
- package/dist/runtime/agent-to-tool.d.ts +51 -0
- package/dist/runtime/agent-to-tool.d.ts.map +1 -0
- package/dist/runtime/agent-to-tool.js +145 -0
- package/dist/runtime/agent-to-tool.js.map +1 -0
- package/dist/runtime/approvals.js +0 -0
- package/dist/runtime/approvals.js.map +1 -0
- package/dist/runtime/dispatch.js +108 -0
- package/dist/runtime/dispatch.js.map +1 -0
- package/dist/runtime/executor-wiring.js +128 -0
- package/dist/runtime/executor-wiring.js.map +1 -0
- package/dist/runtime/fallback-chain.js +139 -0
- package/dist/runtime/fallback-chain.js.map +1 -0
- package/dist/runtime/handoff.js +307 -0
- package/dist/runtime/handoff.js.map +1 -0
- package/dist/runtime/messages.d.ts +22 -0
- package/dist/runtime/messages.d.ts.map +1 -0
- package/dist/runtime/messages.js +204 -0
- package/dist/runtime/messages.js.map +1 -0
- package/dist/runtime/provider-events.js +117 -0
- package/dist/runtime/provider-events.js.map +1 -0
- package/dist/runtime/run-compaction.js +210 -0
- package/dist/runtime/run-compaction.js.map +1 -0
- package/dist/runtime/run-finish.js +48 -0
- package/dist/runtime/run-finish.js.map +1 -0
- package/dist/runtime/run-gates.js +336 -0
- package/dist/runtime/run-gates.js.map +1 -0
- package/dist/runtime/run-init.js +81 -0
- package/dist/runtime/run-init.js.map +1 -0
- package/dist/runtime/run-input.js +46 -0
- package/dist/runtime/run-input.js.map +1 -0
- package/dist/runtime/step-catalogue.js +173 -0
- package/dist/runtime/step-catalogue.js.map +1 -0
- package/dist/runtime/tool-call-walk.js +189 -0
- package/dist/runtime/tool-call-walk.js.map +1 -0
- package/dist/runtime/tool-wiring.js +159 -0
- package/dist/runtime/tool-wiring.js.map +1 -0
- package/dist/tooling/adapters.js +1 -1
- package/dist/tooling/dataflow.js +1 -1
- package/dist/tooling/policy.js +2 -0
- package/dist/tooling/policy.js.map +1 -1
- package/dist/types.d.ts +63 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +20 -20
- package/src/errors/index.ts +320 -0
- package/src/evaluator-optimizer/index.ts +212 -0
- package/src/factory.ts +957 -0
- package/src/fallback/index.ts +108 -0
- package/src/fanout/index.ts +523 -0
- package/src/filters/index.ts +347 -0
- package/src/index.ts +180 -0
- package/src/internal/ids.ts +46 -0
- package/src/internal/usage-accumulator.ts +90 -0
- package/src/lateral-leak/causality-monitor.ts +221 -0
- package/src/lateral-leak/index.ts +35 -0
- package/src/lateral-leak/merge-guard.ts +151 -0
- package/src/lateral-leak/protocol-guard.ts +222 -0
- package/src/preferred-model/index.ts +210 -0
- package/src/progress/index.ts +238 -0
- package/src/run-state/index.ts +607 -0
- package/src/runtime/agent-surface.ts +218 -0
- package/src/runtime/agent-to-tool.ts +323 -0
- package/src/runtime/approvals.ts +0 -0
- package/src/runtime/dispatch.ts +183 -0
- package/src/runtime/executor-wiring.ts +331 -0
- package/src/runtime/fallback-chain.ts +250 -0
- package/src/runtime/handoff.ts +428 -0
- package/src/runtime/messages.ts +309 -0
- package/src/runtime/provider-events.ts +175 -0
- package/src/runtime/run-compaction.ts +288 -0
- package/src/runtime/run-finish.ts +93 -0
- package/src/runtime/run-gates.ts +419 -0
- package/src/runtime/run-init.ts +169 -0
- package/src/runtime/run-input.ts +102 -0
- package/src/runtime/step-catalogue.ts +338 -0
- package/src/runtime/tool-call-walk.ts +301 -0
- package/src/runtime/tool-wiring.ts +218 -0
- package/src/testing/replay-provider.ts +121 -0
- package/src/tooling/adapters.ts +403 -0
- package/src/tooling/catalogue.ts +36 -0
- package/src/tooling/dataflow.ts +171 -0
- package/src/tooling/plan.ts +123 -0
- package/src/tooling/policy.ts +67 -0
- package/src/tooling/registry-build.ts +191 -0
- 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
|
+
}
|