@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,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
|
+
}
|