@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,338 @@
1
+ /**
2
+ * Per-step tool-catalogue support for the agent runtime: projection of
3
+ * a tool's declared schemas onto the provider wire contract
4
+ * (`ToolDefinition`), including worked examples, plus the per-step
5
+ * registry / executor / catalogue / preferred-model resolution.
6
+ * Extracted verbatim from `factory.ts` (issue #23).
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ import type {
12
+ AISpan,
13
+ Message,
14
+ Provider,
15
+ ProviderRequest,
16
+ ReasoningRetention,
17
+ RunState,
18
+ Tool,
19
+ ToolChoice,
20
+ ToolDefinition,
21
+ ToolDefinitionExample,
22
+ } from '@graphorin/core';
23
+ import type { ToolExecutor } from '@graphorin/tools/executor';
24
+ import type { ToolRegistry } from '@graphorin/tools/registry';
25
+ import type { ResultReader } from '@graphorin/tools/result';
26
+ import { projectSchemaToJsonSchema } from '@graphorin/tools/schema';
27
+ import { ToolNotFoundError } from '../errors/index.js';
28
+ import { type PreferredModelResolution, resolvePreferredModel } from '../preferred-model/index.js';
29
+ import { orderPromotedTools } from '../tooling/catalogue.js';
30
+ import { buildToolRegistry } from '../tooling/registry-build.js';
31
+ import type { AgentConfig, PrepareStepOverrides } from '../types.js';
32
+ import { buildHandoffTool, type HandoffEntry } from './handoff.js';
33
+ import { buildStepMessages } from './messages.js';
34
+ import { specToProvider } from './provider-events.js';
35
+ import type { MutableRunState } from './run-input.js';
36
+ import { registerReadResult, registerToolSearch } from './tool-wiring.js';
37
+
38
+ /** WARN-once keys for schemas the projection cannot read (per process). */
39
+ const unprojectableSchemaWarned = new Set<string>();
40
+
41
+ /**
42
+ * Resolve a tool's declared schema - plain Zod (v3/v4), `toJSON()`-bearing,
43
+ * or already-JSON-Schema data - to a JSON Schema record via the shared
44
+ * projection (tools-01). Pre-fix this only honoured `toJSON()` and passed
45
+ * everything else through verbatim, so every plain-Zod tool serialised as
46
+ * `{"_def":...}` internals on OpenAI-shaped/Ollama/vercel wire bodies.
47
+ * `undefined` when nothing usable can be projected (caller substitutes a
48
+ * permissive `{}`), with a WARN so the degradation is never silent.
49
+ */
50
+ function projectSchema(
51
+ raw: unknown,
52
+ toolName: string,
53
+ slot: 'input' | 'output',
54
+ ): Readonly<Record<string, unknown>> | undefined {
55
+ return projectSchemaToJsonSchema(raw, {
56
+ onUnsupported: (detail) => {
57
+ const key = `${toolName}:${slot}:${detail}`;
58
+ if (unprojectableSchemaWarned.has(key)) return;
59
+ unprojectableSchemaWarned.add(key);
60
+ console.warn(
61
+ `[graphorin/agent] tool '${toolName}' ${slot} schema: '${detail}' cannot be projected ` +
62
+ 'to JSON Schema - that fragment degrades to a permissive {} on the provider wire body.',
63
+ );
64
+ },
65
+ });
66
+ }
67
+
68
+ export function toolToDefinition(tool: Tool): ToolDefinition {
69
+ const ts = tool as Tool & {
70
+ readonly inputSchema?: unknown;
71
+ readonly outputSchema?: unknown;
72
+ };
73
+ const inputSchema = projectSchema(ts.inputSchema, tool.name, 'input') ?? {};
74
+ // A5: project the output schema so structured-output providers + typed
75
+ // code-mode see the tool's result shape.
76
+ const outputSchema = projectSchema(ts.outputSchema, tool.name, 'output');
77
+ const examples = renderToolExamples(tool);
78
+ return {
79
+ name: tool.name,
80
+ description: tool.description,
81
+ inputSchema,
82
+ ...(outputSchema !== undefined ? { outputSchema } : {}),
83
+ ...(examples !== undefined ? { examples } : {}),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Project a tool's worked `examples` onto the provider wire contract
89
+ * (WI-06 / P2-3). Examples are rendered only when the tool eagerly
90
+ * renders them: the registry resolves the `defer_loading` auto-rule into
91
+ * `examplesEagerlyRendered`, so a deferred tool resolves to `false` and is
92
+ * skipped (its examples stay out of context even once `tool_search`
93
+ * promotes it). `undefined` - the "runtime decides" case for a plain
94
+ * eager tool - renders, since the tool is already advertised this step.
95
+ *
96
+ * Bounded to ≤5 to honour the `ToolExample` `[1,5]` contract even when a
97
+ * tool slipped past the registry's overflow WARN (which does not truncate).
98
+ */
99
+ function renderToolExamples(tool: Tool): ReadonlyArray<ToolDefinitionExample> | undefined {
100
+ const examples = tool.examples;
101
+ if (examples === undefined || examples.length === 0) return undefined;
102
+ if (tool.examplesEagerlyRendered === false) return undefined;
103
+ return examples.slice(0, 5).map((ex) => ({
104
+ input: ex.input,
105
+ output: ex.output,
106
+ ...(ex.comment !== undefined ? { comment: ex.comment } : {}),
107
+ }));
108
+ }
109
+
110
+ /**
111
+ * The run-scoped inputs of the per-step catalogue resolution. Field
112
+ * names mirror the run-loop locals the former inline block captured.
113
+ */
114
+ export interface StepCatalogueEnv<TDeps, TOutput> {
115
+ readonly config: AgentConfig<TDeps, TOutput>;
116
+ readonly isCodeMode: boolean;
117
+ readonly toolRegistry: ToolRegistry;
118
+ readonly toolExecutor: ToolExecutor;
119
+ readonly makeToolExecutor: (
120
+ registry: ToolRegistry,
121
+ opts?: { readonly quiet?: boolean },
122
+ ) => ToolExecutor;
123
+ readonly resultReader: ResultReader;
124
+ readonly handoffMap: ReadonlyMap<string, HandoffEntry<TDeps>>;
125
+ readonly handoffNames: ReadonlyArray<string>;
126
+ readonly codeModeAdvertised: ReadonlyArray<Tool<unknown, unknown, TDeps>>;
127
+ readonly activeRunCapability: 'read-only' | undefined;
128
+ readonly promotedDeferred: Set<string>;
129
+ readonly runStartPromotions: Set<string> | undefined;
130
+ }
131
+
132
+ /** What one step's catalogue resolution hands back to the run loop. */
133
+ export interface StepToolContext<TDeps> {
134
+ readonly stepRegistry: ToolRegistry;
135
+ readonly stepExecutor: ToolExecutor;
136
+ readonly stepTools: ReadonlyArray<Tool<unknown, unknown, TDeps>>;
137
+ readonly primary: PreferredModelResolution;
138
+ readonly fallbackChain: Provider[];
139
+ }
140
+
141
+ /**
142
+ * Resolve the registry + executor for this step. The warm-up
143
+ * pair is bound to config.tools + skills; a `prepareStep` tool
144
+ * override builds a step-scoped pair so the advertised catalogue
145
+ * and the executor agree on the same tool set (incl. deferred
146
+ * discovery for the overridden set). Code-mode does not honour a
147
+ * per-step `tools` override (the meta-tools + bridge are bound to
148
+ * the warm-up registry), so it always uses the warm-up pair.
149
+ *
150
+ * Also assembles the per-step tool catalogue (code-mode meta-tools or
151
+ * eager + handoffs + promotions, read-only filtered under D2), resolves
152
+ * the step's preferred model (AG-15: consulting only the tools the
153
+ * model CALLED on the previous step), and derives the step's provider
154
+ * fallback chain (RB-48: a `prepareStep` provider override suppresses
155
+ * the chain).
156
+ */
157
+ export function resolveStepToolContext<TDeps, TOutput>(
158
+ env: StepCatalogueEnv<TDeps, TOutput>,
159
+ overrides: PrepareStepOverrides<TDeps>,
160
+ lastStepCalledToolNames: ReadonlyArray<string>,
161
+ ): StepToolContext<TDeps> {
162
+ const { config, isCodeMode, toolRegistry, toolExecutor, makeToolExecutor } = env;
163
+ const { resultReader, handoffMap, handoffNames, codeModeAdvertised } = env;
164
+ const { activeRunCapability, promotedDeferred, runStartPromotions } = env;
165
+
166
+ const useOverrideRegistry = overrides.tools !== undefined && !isCodeMode;
167
+ const stepRegistry: ToolRegistry = useOverrideRegistry
168
+ ? buildToolRegistry({
169
+ tools: overrides.tools as ReadonlyArray<Tool<unknown, unknown, unknown>>,
170
+ }).registry
171
+ : toolRegistry;
172
+ if (useOverrideRegistry) {
173
+ registerToolSearch(
174
+ stepRegistry,
175
+ config.toolPromotion === 'run-boundary' ? 'next-run' : 'next-step',
176
+ );
177
+ registerReadResult(stepRegistry, resultReader);
178
+ }
179
+ const stepExecutor: ToolExecutor = useOverrideRegistry
180
+ ? makeToolExecutor(stepRegistry)
181
+ : toolExecutor;
182
+
183
+ // Build the per-step tool catalogue. Handoff tools are synthetic
184
+ // per-step entries and are always advertised.
185
+ const handoffTools: Tool<unknown, unknown, TDeps>[] = handoffNames.map((n) => {
186
+ const h = handoffMap.get(n);
187
+ if (h === undefined) throw new ToolNotFoundError(n);
188
+ return buildHandoffTool<TDeps>(h.agent);
189
+ });
190
+ // Code-mode (WI-11): advertise only the `code_search` /
191
+ // `code_execute` (+ `read_result`) meta-tools - the model reaches
192
+ // every real tool through `code_execute`, so the real tools stay
193
+ // registered (executable via the in-script bridge) but out of the
194
+ // model's catalogue. Otherwise (WI-05): advertise the eager tools
195
+ // (`tool_search` is itself eager iff a deferred tool exists) plus
196
+ // any deferred tools already promoted by a `tool_search` this run -
197
+ // never the rest of the deferred pool.
198
+ // D2 single-writer constraint: a read-only run never ADVERTISES
199
+ // writer tools (side-effecting / external-stateful) nor handoffs
200
+ // (a transfer hands the writer pen to another agent). The
201
+ // executor-level capability gate backs this up for calls the
202
+ // model fabricates anyway.
203
+ const readOnlyRun = activeRunCapability === 'read-only';
204
+ const keepReadOnly = (t: Tool<unknown, unknown, TDeps>): boolean => {
205
+ const cls = (t as { __sideEffectClass?: string }).__sideEffectClass ?? t.sideEffectClass;
206
+ return cls === 'pure' || cls === 'read-only';
207
+ };
208
+ let stepTools: ReadonlyArray<Tool<unknown, unknown, TDeps>>;
209
+ if (isCodeMode) {
210
+ stepTools = readOnlyRun
211
+ ? [...codeModeAdvertised.filter(keepReadOnly)]
212
+ : [...codeModeAdvertised, ...handoffTools];
213
+ } else {
214
+ const eagerTools = stepRegistry.listEager() as ReadonlyArray<Tool<unknown, unknown, TDeps>>;
215
+ // A7: emit promoted deferred tools in PROMOTION order (append-only) so
216
+ // a later promotion joins the END and the prompt-cache prefix stays
217
+ // byte-stable across steps. C1 (agent-11): handoff tools serialize
218
+ // BEFORE the growing promoted section - handoffs are fixed per run,
219
+ // so the stable prefix is now eager + handoffs + earlier promotions
220
+ // and a new promotion shifts nothing that came before it.
221
+ // 'run-boundary' promotion advertises only the run-start snapshot.
222
+ const advertisedPromotions = runStartPromotions ?? promotedDeferred;
223
+ const promotedTools = (
224
+ advertisedPromotions.size === 0
225
+ ? []
226
+ : orderPromotedTools(advertisedPromotions, stepRegistry.listDeferred())
227
+ ) as ReadonlyArray<Tool<unknown, unknown, TDeps>>;
228
+ stepTools = readOnlyRun
229
+ ? [...eagerTools.filter(keepReadOnly), ...promotedTools.filter(keepReadOnly)]
230
+ : [...eagerTools, ...handoffTools, ...promotedTools];
231
+ }
232
+
233
+ // AG-15: consult the hints of the tools the model CALLED on the
234
+ // previous step - a smart-hinted but never-called tool must not
235
+ // pin the whole conversation to the top cost tier. Steps with no
236
+ // prior calls fall through to the agent-preferred default.
237
+ const calledLastStep = new Set(lastStepCalledToolNames);
238
+ const toolPreferences = stepTools
239
+ .filter((t) => calledLastStep.has(t.name))
240
+ .map((t) => {
241
+ const tt = t as Tool<unknown, unknown, TDeps> & { readonly preferredModel?: unknown };
242
+ return tt.preferredModel as Parameters<
243
+ typeof resolvePreferredModel
244
+ >[0]['toolPreferredModels'][number];
245
+ });
246
+
247
+ const primary: PreferredModelResolution = resolvePreferredModel({
248
+ ...(overrides.provider !== undefined ? { prepareStepProvider: overrides.provider } : {}),
249
+ toolPreferredModels: toolPreferences,
250
+ ...(config.preferredModel !== undefined ? { agentPreferredModel: config.preferredModel } : {}),
251
+ agentDefaultProvider: config.provider,
252
+ ...(config.modelTierMap !== undefined ? { modelTierMap: config.modelTierMap } : {}),
253
+ });
254
+
255
+ // RB-48: when `prepareStep` returns an explicit provider
256
+ // override, the fallback chain is NOT consulted for this
257
+ // step (the operator's explicit choice supersedes the
258
+ // implicit fallback chain).
259
+ const fallbackChain: Provider[] =
260
+ primary.source === 'prepare-step'
261
+ ? [primary.resolvedProvider]
262
+ : [primary.resolvedProvider, ...(config.fallbackModels ?? []).map(specToProvider)];
263
+
264
+ return { stepRegistry, stepExecutor, stepTools, primary, fallbackChain };
265
+ }
266
+
267
+ /** What the per-step request assembly needs from the run loop's scope. */
268
+ export interface StepRequestEnv<TDeps, TOutput> {
269
+ readonly config: AgentConfig<TDeps, TOutput>;
270
+ readonly state: MutableRunState & RunState;
271
+ readonly messages: Message[];
272
+ readonly sessionId: string;
273
+ readonly agentId: string;
274
+ readonly userId: string | undefined;
275
+ readonly signal: AbortSignal;
276
+ readonly structuredInstruction: string | undefined;
277
+ readonly getActiveTodos: () => ReadonlyArray<import('@graphorin/core').TodoItem> | undefined;
278
+ }
279
+
280
+ /**
281
+ * Assemble the step's base {@link ProviderRequest} over the shared
282
+ * message buffer: the D6 request-only trailing additions (structured
283
+ * instruction + plan recitation) ride `buildStepMessages`, the
284
+ * `prepareStep` overrides / config knobs spread in exactly as the
285
+ * inline literal did, and the effective reasoning-retention policy
286
+ * rides the request (RB-42).
287
+ */
288
+ export function buildBaseRequest<TDeps, TOutput>(
289
+ env: StepRequestEnv<TDeps, TOutput>,
290
+ overrides: PrepareStepOverrides<TDeps>,
291
+ toolDefs: ReadonlyArray<ToolDefinition>,
292
+ reasoningPolicy: ReasoningRetention,
293
+ stepNumber: number,
294
+ currentStepSpan: AISpan<'agent.step'> | undefined,
295
+ ): ProviderRequest {
296
+ const { config, state, messages, sessionId, agentId, userId, signal } = env;
297
+ const { structuredInstruction, getActiveTodos } = env;
298
+ return {
299
+ // AG-3 fallback contract: for structured output the request
300
+ // carries ONE trailing system instruction (JSON-only + schema)
301
+ // in the request copy - never in the shared buffer or the
302
+ // persisted RunState. Adapters with native structured output
303
+ // additionally receive `outputType` below (PS-24 consumes it).
304
+ messages: buildStepMessages(messages, structuredInstruction, getActiveTodos()),
305
+ ...(config.outputType !== undefined
306
+ ? {
307
+ outputType: {
308
+ kind: config.outputType.kind,
309
+ ...(config.outputType.description !== undefined
310
+ ? { description: config.outputType.description }
311
+ : {}),
312
+ ...(config.outputType.jsonSchema !== undefined
313
+ ? { jsonSchema: config.outputType.jsonSchema }
314
+ : {}),
315
+ },
316
+ }
317
+ : {}),
318
+ tools: toolDefs,
319
+ ...(overrides.toolChoice !== undefined
320
+ ? { toolChoice: overrides.toolChoice }
321
+ : config.toolChoice !== undefined
322
+ ? { toolChoice: config.toolChoice as ToolChoice }
323
+ : {}),
324
+ metadata: {
325
+ sessionId,
326
+ agentId,
327
+ ...(userId !== undefined ? { userId } : {}),
328
+ runId: state.id,
329
+ stepNumber,
330
+ },
331
+ signal,
332
+ ...(overrides.temperature !== undefined ? { temperature: overrides.temperature } : {}),
333
+ ...(overrides.maxTokens !== undefined ? { maxTokens: overrides.maxTokens } : {}),
334
+ ...(config.cachePolicy !== undefined ? { cachePolicy: config.cachePolicy } : {}),
335
+ ...(currentStepSpan !== undefined ? { parentSpan: currentStepSpan } : {}),
336
+ reasoningRetention: reasoningPolicy,
337
+ };
338
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * The per-step tool-call walk of the agent run loop: batches
3
+ * non-handoff calls for the executor (flushing before a handoff and
4
+ * before each approval-gated call so execution order is kept), executes
5
+ * the ≤1 handoff inline, pre-screens `needsApproval` with validated
6
+ * args (tools-02), collects EVERY gated call (agent-01), and performs
7
+ * the once-per-step durable-HITL suspend with its taint / promotion
8
+ * snapshot + checkpoint (AG-19 / AG-23). Extracted verbatim from
9
+ * `factory.ts` (issue #23); the former inline walk now takes an
10
+ * explicit {@link ToolCallWalkEnv} and returns `{ suspended }` instead
11
+ * of finishing the run itself.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+
16
+ import type {
17
+ AgentEvent,
18
+ CompletedToolCall,
19
+ RunContext,
20
+ ToolApproval,
21
+ ToolCall,
22
+ ToolError,
23
+ } from '@graphorin/core';
24
+ import type { ToolExecutor } from '@graphorin/tools/executor';
25
+ import type { ToolRegistry } from '@graphorin/tools/registry';
26
+ import { serializeRunState } from '../run-state/index.js';
27
+ import type { AgentConfig } from '../types.js';
28
+ import { getSubAgentToolRefs, type SubAgentToolRefs } from './agent-to-tool.js';
29
+ import { invokeNeedsApproval, safeParseGatedArgs } from './approvals.js';
30
+ import type { DispatchBatchFn } from './dispatch.js';
31
+ import {
32
+ executeHandoffToolCall,
33
+ type HandoffEntry,
34
+ type HandoffRunEnv,
35
+ runSubAgentCall,
36
+ } from './handoff.js';
37
+ import { renderToolErrorMessage } from './messages.js';
38
+ import type { AssistantCommitEnv } from './run-gates.js';
39
+ import { isApprovalGated } from './tool-wiring.js';
40
+
41
+ /**
42
+ * The run-scoped context the walk operates on. Extends the handoff
43
+ * execution env (the walk hands it through verbatim); the extra fields
44
+ * mirror the run-loop locals the former inline walk captured.
45
+ */
46
+ export interface ToolCallWalkEnv<TDeps, TOutput> extends HandoffRunEnv<TDeps, TOutput> {
47
+ readonly config: Pick<AgentConfig<TDeps, TOutput>, 'deps' | 'checkpointStore'>;
48
+ readonly handoffMap: ReadonlyMap<string, HandoffEntry<TDeps>>;
49
+ readonly toolDataFlowGuard: AssistantCommitEnv['toolDataFlowGuard'];
50
+ readonly promotedDeferred: Set<string>;
51
+ readonly dispatchBatch: DispatchBatchFn<TDeps, TOutput>;
52
+ }
53
+
54
+ /**
55
+ * Walk calls in finalCalls order. Handoffs are special-cased
56
+ * inline (≤1 per step) and never routed through the executor.
57
+ * Non-handoff calls accumulate into a batch dispatched through
58
+ * the ToolExecutor; the batch is flushed before a handoff and
59
+ * before each approval-gated call so execution order is kept.
60
+ * Gated calls are COLLECTED (all of them, agent-01) and the run
61
+ * suspends once after the walk, so every non-gated toolCallId
62
+ * has a tool message before the suspend snapshot - a dropped
63
+ * call would persist a dangling `tool_use` that real providers
64
+ * reject on resume.
65
+ */
66
+ export async function* processStepToolCalls<TDeps, TOutput>(
67
+ env: ToolCallWalkEnv<TDeps, TOutput>,
68
+ finalCalls: ReadonlyArray<ToolCall>,
69
+ stepRegistry: ToolRegistry,
70
+ stepExecutor: ToolExecutor,
71
+ execRunContext: RunContext<TDeps>,
72
+ stepNumber: number,
73
+ ): AsyncGenerator<
74
+ AgentEvent<TOutput>,
75
+ { readonly suspended: boolean; readonly abortPending?: boolean },
76
+ void
77
+ > {
78
+ const { config, state, messages, signal, handoffMap } = env;
79
+ const { toolDataFlowGuard, promotedDeferred, dispatchBatch } = env;
80
+ let batch: ToolCall[] = [];
81
+ let stepApprovalsRequested = 0;
82
+
83
+ for (const call of finalCalls) {
84
+ const handoff = handoffMap.get(call.toolName);
85
+ if (handoff !== undefined) {
86
+ if (batch.length > 0) {
87
+ yield* dispatchBatch(batch, stepExecutor, execRunContext, stepNumber);
88
+ batch = [];
89
+ }
90
+ const handed = yield* executeHandoffToolCall<TDeps, TOutput>(env, call, handoff, stepNumber);
91
+ // W-001: the handoff child suspended awaiting approvals - it
92
+ // parked on the parent and the parent suspends once per step
93
+ // exactly like a directly-gated call.
94
+ if (handed.suspendRequested) stepApprovalsRequested += 1;
95
+ continue;
96
+ }
97
+
98
+ // Approval pre-screen (Adapter G / durable HITL). Evaluate the
99
+ // registry-resolved `needsApproval`; a gated call flushes the
100
+ // queued batch (prior calls' side-effects complete first) and
101
+ // is recorded as a pending approval. The walk CONTINUES: later
102
+ // gated calls are collected too, and later non-gated calls
103
+ // still execute before the suspend (agent-01 - previously
104
+ // everything after the first gated call was silently dropped,
105
+ // never executed and never approvable, leaving dangling
106
+ // `tool_use` ids in the persisted transcript).
107
+ const resolvedTool = stepRegistry.get(call.toolName);
108
+ // tools-02 (agent mirror): the approval decision must be made
109
+ // on the input that will actually execute. For gated tools the
110
+ // args are validated HERE: a schema failure fails the call fast
111
+ // as `invalid_input` (a human is never asked to approve args
112
+ // that cannot run, and the resumed dispatch can therefore never
113
+ // hit the repair hook), and the predicate receives the parsed
114
+ // value its typed signature promises - not raw pre-coercion
115
+ // JSON.
116
+ let gateInput: unknown = call.args;
117
+ if (resolvedTool !== undefined && isApprovalGated(resolvedTool)) {
118
+ const parsed = safeParseGatedArgs(resolvedTool, call.args);
119
+ if (parsed !== undefined && !parsed.success) {
120
+ const toolError: ToolError = {
121
+ toolCallId: call.toolCallId,
122
+ toolName: call.toolName,
123
+ kind: 'invalid_input',
124
+ message: `Invalid arguments for approval-gated tool '${call.toolName}': ${parsed.message}`,
125
+ };
126
+ const stepEntry = state.steps[state.steps.length - 1];
127
+ if (stepEntry !== undefined) {
128
+ (stepEntry.toolCalls as CompletedToolCall[]).push({
129
+ call,
130
+ outcome: toolError,
131
+ stepNumber,
132
+ });
133
+ }
134
+ yield { type: 'tool.execute.start', toolCallId: call.toolCallId, toolName: call.toolName };
135
+ yield {
136
+ type: 'tool.execute.error',
137
+ toolCallId: call.toolCallId,
138
+ toolName: call.toolName,
139
+ error: toolError,
140
+ };
141
+ const text = renderToolErrorMessage(toolError);
142
+ messages.push({ role: 'tool', toolCallId: call.toolCallId, content: text });
143
+ state.messages.push({ role: 'tool', toolCallId: call.toolCallId, content: text });
144
+ continue;
145
+ }
146
+ if (parsed !== undefined) gateInput = parsed.data;
147
+ }
148
+ const needsApproval = await invokeNeedsApproval(
149
+ resolvedTool,
150
+ gateInput,
151
+ execRunContext,
152
+ signal,
153
+ );
154
+ if (needsApproval) {
155
+ if (batch.length > 0) {
156
+ yield* dispatchBatch(batch, stepExecutor, execRunContext, stepNumber);
157
+ batch = [];
158
+ }
159
+ yield { type: 'tool.execute.start', toolCallId: call.toolCallId, toolName: call.toolName };
160
+ const approval: ToolApproval = {
161
+ toolCallId: call.toolCallId,
162
+ toolName: call.toolName,
163
+ args: call.args,
164
+ requestedAt: new Date().toISOString(),
165
+ };
166
+ state.pendingApprovals.push(approval);
167
+ stepApprovalsRequested += 1;
168
+ yield {
169
+ type: 'tool.approval.requested',
170
+ toolCallId: call.toolCallId,
171
+ };
172
+ continue;
173
+ }
174
+
175
+ // W-001: `toTool` sub-agent tools execute INLINE through the same
176
+ // seam as a handoff (never through the executor, which cannot
177
+ // suspend a run) - a child that parks on `awaiting_approval`
178
+ // suspends the parent instead of surfacing a terminal tool error.
179
+ const subRefs = getSubAgentToolRefs(resolvedTool);
180
+ if (subRefs !== undefined) {
181
+ if (batch.length > 0) {
182
+ yield* dispatchBatch(batch, stepExecutor, execRunContext, stepNumber);
183
+ batch = [];
184
+ }
185
+ const subbed = yield* executeSubAgentToolCall<TDeps, TOutput>(
186
+ env,
187
+ call,
188
+ subRefs,
189
+ execRunContext,
190
+ stepNumber,
191
+ );
192
+ if (subbed.suspendRequested) stepApprovalsRequested += 1;
193
+ continue;
194
+ }
195
+
196
+ batch.push(call);
197
+ }
198
+
199
+ if (batch.length > 0) {
200
+ yield* dispatchBatch(batch, stepExecutor, execRunContext, stepNumber);
201
+ }
202
+
203
+ // Durable-HITL suspend: once per step, carrying EVERY gated call
204
+ // the model batched. Runs after the final batch flush so the
205
+ // suspend snapshot already contains tool messages for the whole
206
+ // non-gated remainder of the step.
207
+ if (stepApprovalsRequested > 0) {
208
+ state.status = 'awaiting_approval';
209
+ // AG-19: persist the coarse taint summary + promoted-tool set into
210
+ // the suspended state so a resume rehydrates the sink gate and the
211
+ // discovered-tool catalogue instead of starting empty.
212
+ const taintSnap = toolDataFlowGuard?.snapshotLedger(state.id);
213
+ if (taintSnap !== undefined) state.taintSummary = taintSnap;
214
+ if (promotedDeferred.size > 0) state.promotedTools = [...promotedDeferred];
215
+ // W-038: an abort that arrived while this step was collecting gated
216
+ // calls must reach the `onPendingApprovals` policy INSTEAD of the
217
+ // unconditional suspend - and no 'suspended awaiting_approval'
218
+ // checkpoint may be written first, or the durable trail would
219
+ // contradict the aborted outcome and resurrect denied approvals on
220
+ // resume. The factory applies the policy and persists the final,
221
+ // policy-consistent state through the same put seam.
222
+ if (signal.aborted) {
223
+ return { suspended: true, abortPending: true };
224
+ }
225
+ if (config.checkpointStore !== undefined) {
226
+ await config.checkpointStore.put(
227
+ state.id,
228
+ 'agent',
229
+ {
230
+ id: state.id,
231
+ threadId: state.id,
232
+ namespace: 'agent',
233
+ // AG-23: persist a detached, secret-redacted snapshot -
234
+ // never the live MutableRunState reference.
235
+ state: serializeRunState(state, { stripTracingApiKey: true }),
236
+ channelVersions: {},
237
+ stepNumber,
238
+ createdAt: new Date().toISOString(),
239
+ },
240
+ { source: 'sync', status: 'suspended', nodeName: 'agent.run', sessionId: state.sessionId },
241
+ );
242
+ }
243
+ return { suspended: true };
244
+ }
245
+ return { suspended: false };
246
+ }
247
+
248
+ /**
249
+ * W-001: execute a `toTool` sub-agent call INLINE (mirroring the
250
+ * handoff seam): reproduce `execute()`'s seed and output shaping via
251
+ * the tool's {@link SubAgentToolRefs}, and settle through the shared
252
+ * {@link runSubAgentCall} so a suspending child parks instead of
253
+ * throwing. The D2 taint fold that the executor would have applied from
254
+ * the ToolReturn envelope is recorded directly on the data-flow guard.
255
+ */
256
+ async function* executeSubAgentToolCall<TDeps, TOutput>(
257
+ env: ToolCallWalkEnv<TDeps, TOutput>,
258
+ call: ToolCall,
259
+ refs: SubAgentToolRefs,
260
+ execRunContext: RunContext<TDeps>,
261
+ stepNumber: number,
262
+ ): AsyncGenerator<AgentEvent<TOutput>, { readonly suspendRequested: boolean }, void> {
263
+ const { config, options, messages, sessionId, signal, toolDataFlowGuard } = env;
264
+ yield { type: 'tool.execute.start', toolCallId: call.toolCallId, toolName: call.toolName };
265
+ const rawInput = (call.args ?? {}) as { readonly input?: unknown };
266
+ const input = { input: typeof rawInput.input === 'string' ? rawInput.input : '' };
267
+ const parentSpan = env.getCurrentStepSpan?.();
268
+ const callOpts: Record<string, unknown> = {
269
+ signal,
270
+ ...(options.deps !== undefined || config.deps !== undefined
271
+ ? { deps: options.deps ?? config.deps }
272
+ : {}),
273
+ sessionId,
274
+ ...(refs.capability !== undefined ? { capability: refs.capability } : {}),
275
+ // W-036: one trace tree through the inline walk too.
276
+ ...(parentSpan !== undefined ? { parentSpan } : {}),
277
+ };
278
+ const seed = refs.buildSeed(input, messages);
279
+ const subStream = refs.stream(seed, callOpts);
280
+ return yield* runSubAgentCall<TDeps, TOutput>(
281
+ env,
282
+ call,
283
+ {
284
+ agentName: refs.agentName,
285
+ subStream,
286
+ errorLabel: `sub-agent '${refs.agentName}'`,
287
+ renderCompleted: refs.shapeCompleted,
288
+ ...(refs.forwardEvents !== undefined ? { forwardEvents: refs.forwardEvents } : {}),
289
+ recordTaint: (taint, renderedText) => {
290
+ toolDataFlowGuard?.record({
291
+ toolName: call.toolName,
292
+ trustClass: 'first-party-user-defined',
293
+ taintOverride: taint,
294
+ outputText: renderedText,
295
+ runContext: execRunContext as RunContext,
296
+ });
297
+ },
298
+ },
299
+ stepNumber,
300
+ );
301
+ }