@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
package/src/factory.ts ADDED
@@ -0,0 +1,957 @@
1
+ /**
2
+ * `createAgent({...})` - the agent factory entry point.
3
+ *
4
+ * Wires the typed `model -> tool calls -> model` loop, the streamed
5
+ * event surface, the steering / followUp queues, durable HITL via
6
+ * `RunState`, the multi-agent handoff layer, the agent-level model
7
+ * fallback chain, and the per-tool preferred-model resolution.
8
+ *
9
+ * Custom adapters override behaviour by supplying alternative
10
+ * `Provider` / `Memory` / `CheckpointStore` instances; the loop
11
+ * never reaches into adapter internals.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+
16
+ import type {
17
+ AgentEvent,
18
+ AgentResult,
19
+ AISpan,
20
+ Message,
21
+ ProviderRequest,
22
+ RunContext,
23
+ RunState,
24
+ RunStep,
25
+ Sensitivity,
26
+ Tool,
27
+ ToolApproval,
28
+ ToolCall,
29
+ ToolDefinition,
30
+ } from '@graphorin/core';
31
+ import { isStepCount, NOOP_TRACER } from '@graphorin/core';
32
+ import type { Memory } from '@graphorin/memory';
33
+
34
+ import {
35
+ AgentRuntimeError,
36
+ ConcurrentRunError,
37
+ InvalidAgentConfigError,
38
+ InvalidPreferredModelError,
39
+ MultipleHandoffsInStepError,
40
+ } from './errors/index.js';
41
+ import type { AgentFallbackPolicy } from './fallback/index.js';
42
+ import type { DescribedFilter } from './filters/index.js';
43
+ import { newId } from './internal/ids.js';
44
+ import { InMemoryUsageAccumulator } from './internal/usage-accumulator.js';
45
+ import { CausalityMonitor } from './lateral-leak/causality-monitor.js';
46
+ import { createProgressIO, type ProgressIO } from './progress/index.js';
47
+ import { addModelUsage, serializeRunState } from './run-state/index.js';
48
+ import {
49
+ createCompactMethod,
50
+ createFanOutMethod,
51
+ createProgressSurface,
52
+ createRunMethods,
53
+ } from './runtime/agent-surface.js';
54
+ import { createToTool } from './runtime/agent-to-tool.js';
55
+ import {
56
+ dispatchResumedApprovals,
57
+ processResumeDirective,
58
+ processSubRunResumes,
59
+ type ResumedDispatchEnv,
60
+ type ResumeRunEnv,
61
+ type RoutedSubRunDecision,
62
+ type SubRunResumeEnv,
63
+ } from './runtime/approvals.js';
64
+ import { type DispatchRunEnv, dispatchToolBatch } from './runtime/dispatch.js';
65
+ import { wireToolExecution } from './runtime/executor-wiring.js';
66
+ import { type FallbackChainEnv, runProviderFallbackChain } from './runtime/fallback-chain.js';
67
+ import { HANDOFF_TOOL_PREFIX, isDescribedFilter } from './runtime/handoff.js';
68
+ import {
69
+ accumulateUsage,
70
+ applyReasoningRetention,
71
+ countLeadingSystemMessages,
72
+ } from './runtime/messages.js';
73
+ import {
74
+ type CompactionRunEnv,
75
+ maybeAutoCompact,
76
+ noopCompactionResult,
77
+ type PendingManualCompact,
78
+ tryEmergencyCompact,
79
+ } from './runtime/run-compaction.js';
80
+ import { createRunFinisher } from './runtime/run-finish.js';
81
+ import {
82
+ type AssistantCommitEnv,
83
+ buildStructuredInstruction,
84
+ type CancellationEnv,
85
+ commitAssistantMessage,
86
+ emitCancellation,
87
+ finalizeRunOutput,
88
+ type GuardrailScreenEnv,
89
+ type RunOutputEnv,
90
+ runVerifierGate,
91
+ screenInputGuardrails,
92
+ type VerifierGateEnv,
93
+ } from './runtime/run-gates.js';
94
+ import { initializeRunState, seedInitialMessages } from './runtime/run-init.js';
95
+ import {
96
+ asMessages,
97
+ type InternalRunSnapshot,
98
+ isModelHintLike,
99
+ isModelSpecLike,
100
+ type MutableRunState,
101
+ validatePreferredModel,
102
+ } from './runtime/run-input.js';
103
+ import {
104
+ buildBaseRequest,
105
+ resolveStepToolContext,
106
+ type StepCatalogueEnv,
107
+ type StepRequestEnv,
108
+ toolToDefinition,
109
+ } from './runtime/step-catalogue.js';
110
+ import { processStepToolCalls, type ToolCallWalkEnv } from './runtime/tool-call-walk.js';
111
+ import type { ExecutorEventBridge } from './tooling/adapters.js';
112
+ import type {
113
+ AbortOptions,
114
+ Agent,
115
+ AgentCallOptions,
116
+ AgentConfig,
117
+ AgentInput,
118
+ AgentProgressIO,
119
+ SubagentForwardPolicy,
120
+ } from './types.js';
121
+
122
+ /**
123
+ * Build a fresh {@link Agent} from the supplied configuration.
124
+ *
125
+ * @stable
126
+ */
127
+ export function createAgent<TDeps = unknown, TOutput = string>(
128
+ config: AgentConfig<TDeps, TOutput>,
129
+ ): Agent<TDeps, TOutput> {
130
+ if (typeof config.name !== 'string' || config.name.length === 0) {
131
+ throw new InvalidAgentConfigError("missing 'name'");
132
+ }
133
+ if (config.provider === undefined || config.provider === null) {
134
+ throw new InvalidAgentConfigError("missing 'provider'");
135
+ }
136
+ // AG-3: a schema on a text-kind output spec is a config mistake (the
137
+ // schema would never run) - reject instead of silently ignoring.
138
+ if (config.outputType?.kind === 'text' && config.outputType.schema !== undefined) {
139
+ throw new InvalidAgentConfigError(
140
+ "outputType.kind 'text' with a schema - did you mean kind: 'structured'?",
141
+ );
142
+ }
143
+ validatePreferredModel(config.preferredModel);
144
+ if (config.modelTierMap !== undefined) {
145
+ for (const [tier, spec] of Object.entries(config.modelTierMap)) {
146
+ if (!isModelHintLike(tier)) throw new InvalidPreferredModelError({ tier });
147
+ if (spec === undefined) continue;
148
+ if (!isModelSpecLike(spec)) throw new InvalidPreferredModelError(spec);
149
+ }
150
+ }
151
+ if (config.fallbackModels !== undefined) {
152
+ for (const spec of config.fallbackModels) {
153
+ if (!isModelSpecLike(spec)) throw new InvalidPreferredModelError(spec);
154
+ }
155
+ }
156
+
157
+ const agentId = newId('agent');
158
+ const tracer = config.tracer ?? NOOP_TRACER;
159
+ const stopWhen = config.stopWhen ?? isStepCount(50);
160
+ const fallbackPolicy: AgentFallbackPolicy = config.fallbackPolicy ?? {};
161
+ const handoffMap = new Map<
162
+ string,
163
+ {
164
+ readonly agent: Agent<TDeps, unknown>;
165
+ readonly filter: DescribedFilter | undefined;
166
+ readonly forwardEvents: SubagentForwardPolicy | undefined;
167
+ }
168
+ >();
169
+ for (const entry of config.handoffs ?? []) {
170
+ const isWrappedHandoff = typeof entry === 'object' && entry !== null && 'target' in entry;
171
+ const subAgent: Agent<TDeps, unknown> = isWrappedHandoff
172
+ ? (entry as { readonly target: Agent<TDeps, unknown> }).target
173
+ : (entry as Agent<TDeps, unknown>);
174
+ const userFilter = isWrappedHandoff
175
+ ? (
176
+ entry as {
177
+ readonly inputFilter?:
178
+ | DescribedFilter
179
+ | ((history: readonly Message[]) => readonly Message[]);
180
+ }
181
+ ).inputFilter
182
+ : undefined;
183
+ const filter = isDescribedFilter(userFilter) ? userFilter : undefined;
184
+ const forwardEvents = isWrappedHandoff
185
+ ? (entry as { readonly forwardEvents?: SubagentForwardPolicy }).forwardEvents
186
+ : undefined;
187
+ const toolName = `${HANDOFF_TOOL_PREFIX}${subAgent.config.name}`;
188
+ handoffMap.set(toolName, { agent: subAgent, filter, forwardEvents });
189
+ }
190
+
191
+ let pendingSteer: Message[] = [];
192
+ const pendingFollowUp: Message[] = [];
193
+ let abortController: AbortController | undefined;
194
+ let pendingAbort: AbortOptions | undefined;
195
+ // Per-run scratch refs surfaced through the public surface for
196
+ // event emission from `steer(...)` / `followUp(...)`.
197
+ let activeRunState: RunState | undefined;
198
+ // D2: capability of the ACTIVE run - read by the code-mode bridge so
199
+ // in-script tool calls inherit the run's single-writer restriction.
200
+ let activeRunCapability: 'read-only' | undefined;
201
+ /** AG-11: guards the one-in-flight-run-per-instance invariant. */
202
+ let runInFlight = false;
203
+ const externalEventQueue: AgentEvent<TOutput>[] = [];
204
+
205
+ const pendingManualCompacts: PendingManualCompact[] = [];
206
+
207
+ const memory: Memory | undefined = config.memory;
208
+ const progressIO: ProgressIO = createProgressIO({
209
+ ...(config.sensitivity !== undefined ? { defaultSensitivity: config.sensitivity } : {}),
210
+ });
211
+
212
+ // Warm-up tool stack (Principle #12 / WI-03 / WI-05 / WI-10 / WI-11 /
213
+ // WI-13 / D6): registry assembly, built-in registration (tool_search,
214
+ // plan, read_result), guard hooks, the shared ToolExecutor factory and
215
+ // the code-mode surface - wired in `runtime/executor-wiring.ts`. The
216
+ // registry is exposed read-only as `agent.registry`.
217
+ const executorBridgeSlot: { current: ExecutorEventBridge | undefined } = { current: undefined };
218
+ const {
219
+ toolRegistry,
220
+ spillWriter,
221
+ resultReader,
222
+ makeToolExecutor,
223
+ toolExecutor,
224
+ toolDataFlowGuard,
225
+ ruleOfTwoCapabilityFloor,
226
+ isCodeMode,
227
+ codeModeAdvertised,
228
+ } = wireToolExecution<TDeps, TOutput>({
229
+ config,
230
+ memory,
231
+ agentId,
232
+ handoffToolNames: handoffMap.keys(),
233
+ getActiveRunState: () => activeRunState,
234
+ getActiveRunCapability: () => activeRunCapability,
235
+ executorBridgeSlot,
236
+ });
237
+
238
+ const causalityMonitor = config.causalityMonitor
239
+ ? new CausalityMonitor(config.causalityMonitor)
240
+ : undefined;
241
+
242
+ /**
243
+ * AG-11: one in-flight run per Agent instance. `steer` / `followUp` /
244
+ * `abort` / `compact` address "the run" with no run handle, so
245
+ * overlapping runs on one instance cannot be expressed safely - they
246
+ * would share the abort controller, steer queue, active-run ref and
247
+ * executor bridge. A second concurrent `run()` / `stream()` rejects
248
+ * with {@link ConcurrentRunError}; run-scoped state is reset on entry
249
+ * (a steer/abort queued after the previous run ended belongs to NO
250
+ * run) and cleared in a `finally` that also covers abandoned streams
251
+ * (consumer `break`) and thrown runs.
252
+ */
253
+ async function* runLoop(
254
+ input: AgentInput | RunState,
255
+ options: AgentCallOptions<TDeps>,
256
+ ): AsyncGenerator<AgentEvent<TOutput>, AgentResult<TOutput>, void> {
257
+ if (runInFlight) {
258
+ throw new ConcurrentRunError();
259
+ }
260
+ runInFlight = true;
261
+ pendingSteer = [];
262
+ pendingAbort = undefined;
263
+ // D2 + D4: per-run capability - the call-level override wins, then
264
+ // the agent default, then the Rule-of-Two floor (a profile denying
265
+ // external side effects forces read-only even without an explicit
266
+ // capability). Absent ⇒ all capabilities (legacy behaviour).
267
+ activeRunCapability = options.capability ?? config.capability ?? ruleOfTwoCapabilityFloor;
268
+ // AG-10: the causality chain is a per-run artifact - a denial
269
+ // recorded in one run must not poison detection in the next.
270
+ causalityMonitor?.reset();
271
+ try {
272
+ return yield* runLoopInner(input, options);
273
+ } finally {
274
+ runInFlight = false;
275
+ activeRunState = undefined;
276
+ activeRunCapability = undefined;
277
+ // Backstop for exits that bypass `finishRun` (abandoned stream,
278
+ // generator teardown): settle queued manual compactions so no
279
+ // `agent.compact()` promise is left hanging (CE-3/AG-13).
280
+ while (pendingManualCompacts.length > 0) {
281
+ pendingManualCompacts.shift()?.resolve(noopCompactionResult('no-active-run'));
282
+ }
283
+ }
284
+ }
285
+
286
+ async function* runLoopInner(
287
+ input: AgentInput | RunState,
288
+ options: AgentCallOptions<TDeps>,
289
+ ): AsyncGenerator<AgentEvent<TOutput>, AgentResult<TOutput>, void> {
290
+ const { seed: rawSeed, resumed } = asMessages(input);
291
+ // AG-12: queued follow-ups are next-turn metadata - they ride into
292
+ // the next FRESH run as leading user turns. The old path mutated a
293
+ // finished run back to 'running' and appended the message to a loop
294
+ // that never processed it, leaving a non-terminal persisted RunState
295
+ // with a dangling user turn. Resumed runs keep the queue intact.
296
+ const seed =
297
+ resumed === undefined && pendingFollowUp.length > 0
298
+ ? [...pendingFollowUp.splice(0, pendingFollowUp.length), ...rawSeed]
299
+ : rawSeed;
300
+ const sessionId = options.sessionId ?? config.sessionId ?? `session_${newId()}`;
301
+ const userId = options.userId ?? config.userId;
302
+ const localCtl = new AbortController();
303
+ abortController = localCtl;
304
+ // AG-5: the loop + every provider request must observe the LOCAL
305
+ // controller, so `agent.abort()` (which aborts `localCtl`) is honoured even
306
+ // when the caller supplied their own `options.signal`. The caller's signal
307
+ // is propagated INTO `localCtl` by the listener below; the listener is torn
308
+ // down in the run's `finally` so it does not accumulate across runs that
309
+ // share one long-lived parent signal.
310
+ const signal = localCtl.signal;
311
+ const parentSignal = options.signal;
312
+ const onParentAbort = (): void => localCtl.abort();
313
+ if (parentSignal !== undefined) {
314
+ if (parentSignal.aborted) localCtl.abort();
315
+ else parentSignal.addEventListener('abort', onParentAbort);
316
+ }
317
+
318
+ const usageAcc = new InMemoryUsageAccumulator();
319
+ // Bootstrap the run state, the AG-19 security rehydration and the
320
+ // `tool_search` promotion set (see `runtime/run-init.ts`).
321
+ const { state, promotedDeferred, runStartPromotions } = initializeRunState<TDeps, TOutput>(
322
+ { config, agentId, sessionId, userId, toolDataFlowGuard },
323
+ resumed,
324
+ );
325
+ activeRunState = state;
326
+
327
+ // agent-08 (F4): capture the run-scoped security state on EVERY exit
328
+ // through finishRun - not just the approval suspend. An 'aborted' run
329
+ // is resumable (the AG-14 guard blocks only awaiting_approval/failed)
330
+ // and a 'completed' run re-enters as a follow-up; both must rehydrate
331
+ // the enforce-mode sink gate and the discovered-tool catalogue.
332
+ // Shadows the factory-scope finishRunBase for every call in this run.
333
+ async function* finishRun(
334
+ s: MutableRunState & RunState,
335
+ snapshot: InternalRunSnapshot<TOutput>,
336
+ ): AsyncGenerator<AgentEvent<TOutput>, AgentResult<TOutput>, void> {
337
+ const taintSnap = toolDataFlowGuard?.snapshotLedger(s.id);
338
+ if (taintSnap !== undefined) {
339
+ (s as { taintSummary?: typeof taintSnap }).taintSummary = taintSnap;
340
+ }
341
+ if (promotedDeferred.size > 0) {
342
+ (s as { promotedTools?: readonly string[] }).promotedTools = [...promotedDeferred];
343
+ }
344
+ return yield* finishRunBase(s, snapshot);
345
+ }
346
+
347
+ const messages: Message[] = resumed ? [...state.messages] : [];
348
+ if (!resumed) {
349
+ // Fresh run: resolve `instructions` (AG-8), optionally assemble
350
+ // the memory-aware system prompt (CE-1), and seed the buffer +
351
+ // RunState mirror (see `runtime/run-init.ts`).
352
+ await seedInitialMessages<TDeps, TOutput>(
353
+ { config, options, memory, agentId, sessionId, userId, tracer, signal, usageAcc, state },
354
+ messages,
355
+ seed,
356
+ );
357
+ }
358
+
359
+ const finalSnapshot: InternalRunSnapshot<TOutput> = {
360
+ output: '' as unknown as TOutput,
361
+ };
362
+
363
+ // C7: one agent.run span per run; step/tool/provider spans parent
364
+ // under it so the whole run is a single trace tree. Attributes follow
365
+ // the OTel GenAI semantic conventions (gen_ai.*).
366
+ const runSpan = tracer.startSpan({
367
+ type: 'agent.run',
368
+ // W-036: a sub-agent invocation parents under the caller's live
369
+ // step/tool span so multi-agent load forms ONE trace tree.
370
+ ...(options.parentSpan !== undefined ? { parent: options.parentSpan } : {}),
371
+ attrs: {
372
+ 'gen_ai.operation.name': 'invoke_agent',
373
+ 'gen_ai.agent.id': agentId,
374
+ 'gen_ai.agent.name': config.name,
375
+ 'graphorin.run.id': state.id,
376
+ 'graphorin.session.id': sessionId,
377
+ 'graphorin.run.resumed': resumed !== undefined,
378
+ },
379
+ });
380
+ let currentStepSpan: AISpan<'agent.step'> | undefined;
381
+
382
+ yield { type: 'agent.start', runId: state.id, agentId };
383
+
384
+ // AG-2 / SDF-4: input guardrails screen each fresh-run seed user
385
+ // message (string content) BEFORE the first provider call, using the
386
+ // canonical `@graphorin/security` composer. 'block' fails the run
387
+ // without reaching the model; 'rewrite' replaces the content in both
388
+ // the working buffer and the persisted RunState; 'warn' logs and
389
+ // continues. Resumed runs skip the pass - their seed was screened
390
+ // when first submitted.
391
+ const inputGuards = config.guardrails?.input;
392
+ if (!resumed && inputGuards !== undefined && inputGuards.length > 0) {
393
+ const blocked = yield* screenInputGuardrails<TOutput>(
394
+ { state, messages, sessionId, agentId },
395
+ inputGuards,
396
+ );
397
+ if (blocked) {
398
+ return yield* finishRun(state, finalSnapshot);
399
+ }
400
+ }
401
+
402
+ // AG-3: one per-run JSON instruction for structured output, appended
403
+ // to each provider request (never the shared buffer / RunState).
404
+ const structuredInstruction =
405
+ config.outputType?.kind === 'structured'
406
+ ? buildStructuredInstruction(config.outputType)
407
+ : undefined;
408
+
409
+ // AG-1: approved gated calls collected from a resume directive, executed
410
+ // for real once every approval is resolved (see the dispatch below).
411
+ const resumedApprovedCalls: ToolCall[] = [];
412
+ // agent-02: the ToolApproval records behind `resumedApprovedCalls`,
413
+ // kept so the write-ahead intent checkpoint can re-attach them to
414
+ // `pendingApprovals` (a crash-retry against the intent re-dispatches).
415
+ const grantedApprovals: ToolApproval[] = [];
416
+ // W-001: decisions addressed to PARKED sub-agent runs, grouped by the
417
+ // parent toolCallId of the park (routed into the children below).
418
+ const subRunDecisions = new Map<string, RoutedSubRunDecision[]>();
419
+ // Process resume directive - apply approval decisions to any
420
+ // pending approvals captured in the previous suspend.
421
+ if (
422
+ resumed &&
423
+ options.directive?.approvals !== undefined &&
424
+ state.pendingApprovals.length > 0
425
+ ) {
426
+ yield* processResumeDirective<TOutput>(
427
+ { state, messages },
428
+ options.directive.approvals,
429
+ resumedApprovedCalls,
430
+ grantedApprovals,
431
+ subRunDecisions,
432
+ );
433
+ }
434
+ // AG-14: the resumed status is left untouched here. A 'failed' run is NOT
435
+ // silently rewritten to 'completed' (the terminal/suspended guard below
436
+ // returns it as-is); a 'completed' run keeps its status and re-enters the
437
+ // loop for a follow-up; an unresolved 'awaiting_approval' run is caught by
438
+ // that same guard.
439
+
440
+ // WI-09: pin the trusted system-prompt prefix length now, on the
441
+ // fully-assembled initial buffer, so auto-compaction never rewrites
442
+ // it and prior summaries stay re-compactable (see
443
+ // `countLeadingSystemMessages`).
444
+ const systemPrefixLength = countLeadingSystemMessages(messages);
445
+
446
+ const runContextBase: RunContext<TDeps> = {
447
+ runId: state.id,
448
+ sessionId,
449
+ ...(userId !== undefined ? { userId } : {}),
450
+ agentId,
451
+ deps: (options.deps ?? config.deps) as TDeps,
452
+ tracer,
453
+ signal,
454
+ usage: usageAcc,
455
+ stepNumber: 0,
456
+ messages,
457
+ state,
458
+ };
459
+
460
+ const handoffNames = Array.from(handoffMap.keys());
461
+
462
+ /**
463
+ * One run-scoped env threaded into every extracted runtime module
464
+ * (issue #23): the intersection structurally satisfies each
465
+ * module's env interface, so a single object carries the live loop
466
+ * references the former closures captured (field names mirror the
467
+ * original locals). `getPendingAbort` / `getActiveTodos` are live
468
+ * reads of the factory's mutable scratch; `tryEmergencyCompact` and
469
+ * `dispatchBatch` are pre-bound to this same env.
470
+ */
471
+ type RunLoopEnv = DispatchRunEnv &
472
+ CompactionRunEnv &
473
+ CancellationEnv &
474
+ GuardrailScreenEnv &
475
+ AssistantCommitEnv &
476
+ FallbackChainEnv<TOutput> &
477
+ StepCatalogueEnv<TDeps, TOutput> &
478
+ StepRequestEnv<TDeps, TOutput> &
479
+ ToolCallWalkEnv<TDeps, TOutput> &
480
+ ResumeRunEnv &
481
+ ResumedDispatchEnv<TDeps, TOutput> &
482
+ SubRunResumeEnv<TDeps, TOutput> &
483
+ VerifierGateEnv<TDeps, TOutput> &
484
+ RunOutputEnv<TDeps, TOutput>;
485
+ const runEnv: RunLoopEnv = {
486
+ config,
487
+ options,
488
+ memory,
489
+ state,
490
+ messages,
491
+ sessionId,
492
+ agentId,
493
+ userId,
494
+ signal,
495
+ usageAcc,
496
+ stopWhen,
497
+ fallbackPolicy,
498
+ structuredInstruction,
499
+ systemPrefixLength,
500
+ runContextBase,
501
+ handoffMap,
502
+ handoffNames,
503
+ isCodeMode,
504
+ toolRegistry,
505
+ toolExecutor,
506
+ makeToolExecutor,
507
+ resultReader,
508
+ codeModeAdvertised,
509
+ causalityMonitor,
510
+ toolDataFlowGuard,
511
+ promotedDeferred,
512
+ runStartPromotions,
513
+ activeRunCapability,
514
+ executorBridgeSlot,
515
+ pendingManualCompacts,
516
+ getPendingAbort: () => pendingAbort,
517
+ // W-036: live read of the current step span for sub-agent trace
518
+ // stitching (the child's agent.run parents under it).
519
+ getCurrentStepSpan: () => currentStepSpan,
520
+ getActiveTodos: () => activeRunState?.todos,
521
+ tryEmergencyCompact: () => tryEmergencyCompact<TOutput>(runEnv),
522
+ dispatchBatch: (calls, executor, runContext, stepNum, dispatchOpts) =>
523
+ dispatchToolBatch<TDeps, TOutput>(
524
+ runEnv,
525
+ calls,
526
+ executor,
527
+ runContext,
528
+ stepNum,
529
+ dispatchOpts,
530
+ ),
531
+ };
532
+
533
+ // AG-14 (failed half): a terminal-failed run must never re-enter the
534
+ // provider loop or dispatch anything - that would silently complete a
535
+ // failed run. Return it as-is.
536
+ if (resumed && state.status === 'failed') {
537
+ return yield* finishRun(state, finalSnapshot);
538
+ }
539
+
540
+ // AG-1 / agent-02 / agent-07: execute the approved gated calls for
541
+ // REAL before the provider loop, bracketed by the write-ahead intent
542
+ // + post-dispatch checkpoints (see `runtime/approvals.ts`).
543
+ if (resumed && resumedApprovedCalls.length > 0) {
544
+ yield* dispatchResumedApprovals<TDeps, TOutput>(
545
+ runEnv,
546
+ resumedApprovedCalls,
547
+ grantedApprovals,
548
+ );
549
+ }
550
+
551
+ // W-001: route sub-run decisions into their parked children (grant
552
+ // and deny alike - the child settles them in its own transcript). A
553
+ // child that completes folds back as the parent's tool message; one
554
+ // that suspends again re-parks and the parent re-suspends below.
555
+ if (resumed && subRunDecisions.size > 0) {
556
+ yield* processSubRunResumes<TDeps, TOutput>(runEnv, subRunDecisions);
557
+ }
558
+
559
+ // AG-14 (suspended half): a resumed run still awaiting approvals the
560
+ // directive did not resolve must not re-enter the provider loop - that
561
+ // would re-issue a dangling tool_use real providers reject. The granted
562
+ // subset (above) HAS executed and is journaled; return the re-suspended
563
+ // state carrying its results. The same guard covers a held 'aborted'
564
+ // state (W-038, `onPendingApprovals: 'hold'`): its pending approvals
565
+ // still map to dangling tool_use, so it resumes only through an
566
+ // explicit directive.
567
+ if (
568
+ resumed &&
569
+ (state.status === 'awaiting_approval' ||
570
+ (state.status === 'aborted' && state.pendingApprovals.length > 0))
571
+ ) {
572
+ return yield* finishRun(state, finalSnapshot);
573
+ }
574
+
575
+ // W-035: seed from the journal so post-resume steps continue the
576
+ // numbering instead of colliding with pre-suspend steps (a fresh
577
+ // run's empty journal keeps the historical 0 start).
578
+ let stepNumber = state.steps.reduce((max, s) => Math.max(max, s.stepNumber), 0);
579
+ // C3: verifier-triggered continuation rounds consumed this run.
580
+ let verifierRoundsUsed = 0;
581
+ // AG-15: tools the model actually called on the PREVIOUS step - the
582
+ // per-tool preferred-model ladder consults these, never the full
583
+ // advertised catalogue.
584
+ let lastStepCalledToolNames: ReadonlyArray<string> = [];
585
+
586
+ try {
587
+ while (!stopWhen.check(state)) {
588
+ // Drain any externally-queued lifecycle events
589
+ // (`agent.steered`, `agent.followup.queued`).
590
+ while (externalEventQueue.length > 0) {
591
+ const ev = externalEventQueue.shift();
592
+ if (ev !== undefined) yield ev;
593
+ }
594
+ if (signal.aborted) {
595
+ if (yield* emitCancellation<TOutput>(runEnv)) {
596
+ return yield* finishRun(state, finalSnapshot);
597
+ }
598
+ break;
599
+ }
600
+ stepNumber += 1;
601
+ const stepStart = new Date().toISOString();
602
+
603
+ // Drain steering queue.
604
+ if (pendingSteer.length > 0) {
605
+ for (const m of pendingSteer) {
606
+ messages.push(m);
607
+ state.messages.push(m);
608
+ }
609
+ pendingSteer = [];
610
+ }
611
+
612
+ yield { type: 'step.start', stepNumber };
613
+ // C7: defensive end for a span left open by a mid-step exit path.
614
+ currentStepSpan?.end();
615
+ currentStepSpan = tracer.startSpan({
616
+ type: 'agent.step',
617
+ parent: runSpan,
618
+ attrs: {
619
+ 'gen_ai.operation.name': 'invoke_agent',
620
+ 'gen_ai.agent.id': agentId,
621
+ 'gen_ai.agent.name': config.name,
622
+ 'graphorin.run.id': state.id,
623
+ 'graphorin.step.number': stepNumber,
624
+ },
625
+ });
626
+
627
+ // WI-09 (P1-1): bound context growth before the provider call.
628
+ // Fires `context.compacted` and rewrites the buffer in place only
629
+ // when the memory ContextEngine's trigger crosses threshold; a
630
+ // no-memory / below-threshold / secret-tier step is a no-op, so
631
+ // the happy-path event stream is unchanged (R10).
632
+ yield* maybeAutoCompact<TOutput>(runEnv);
633
+
634
+ const stepCtx: RunContext<TDeps> = {
635
+ ...runContextBase,
636
+ stepNumber,
637
+ messages,
638
+ ...(currentStepSpan !== undefined ? { span: currentStepSpan } : {}),
639
+ };
640
+ const overrides = config.prepareStep ? await config.prepareStep(stepCtx) : {};
641
+
642
+ // Resolve the step's registry / executor / catalogue / preferred
643
+ // model / fallback chain (see `runtime/step-catalogue.ts`).
644
+ const { stepRegistry, stepExecutor, stepTools, primary, fallbackChain } =
645
+ resolveStepToolContext<TDeps, TOutput>(runEnv, overrides, lastStepCalledToolNames);
646
+
647
+ // Resolve the effective reasoning-retention policy for this step
648
+ // (RB-42), dropping buffered reasoning when the contract
649
+ // downgrades to `'strip'` (see `runtime/messages.ts`).
650
+ const reasoningPolicy = applyReasoningRetention(
651
+ config.reasoningRetention,
652
+ primary.resolvedProvider,
653
+ messages,
654
+ state.messages,
655
+ );
656
+
657
+ const toolDefs: ReadonlyArray<ToolDefinition> = stepTools.map((t) =>
658
+ toolToDefinition(t as Tool<unknown, unknown, unknown>),
659
+ );
660
+
661
+ // Assemble the step's base provider request (AG-3 / D6 / RB-42;
662
+ // see `runtime/step-catalogue.ts`).
663
+ const baseRequest: ProviderRequest = buildBaseRequest<TDeps, TOutput>(
664
+ runEnv,
665
+ overrides,
666
+ toolDefs,
667
+ reasoningPolicy,
668
+ stepNumber,
669
+ currentStepSpan,
670
+ );
671
+
672
+ // Stream the step's provider call across the fallback chain
673
+ // (see `runtime/fallback-chain.ts`); a terminal provider
674
+ // failure is already recorded on `state` when `failed` is set.
675
+ const chain = yield* runProviderFallbackChain<TOutput>(
676
+ runEnv,
677
+ fallbackChain,
678
+ baseRequest,
679
+ primary,
680
+ stepNumber,
681
+ );
682
+ if (chain.failed) {
683
+ return yield* finishRun(state, finalSnapshot);
684
+ }
685
+ const { modelSucceeded, textBuffer, finalCalls, stepReasoningParts } = chain;
686
+ const { stepUsage, lastModelId } = chain;
687
+
688
+ // AG-6: a mid-stream abort that interrupted the stream (no completed
689
+ // model) ends the run as a cancellation - 'aborted', or 'failed'
690
+ // under `onPendingApprovals: 'fail'` ONLY when approvals are
691
+ // actually pending (W-038; an empty queue aborts plainly) - rather
692
+ // than falling through to a 'no-provider-completed' failure. When
693
+ // the model DID complete (e.g. `drain: true` let the step finish),
694
+ // fall through so the step's tool calls run and the graceful stop
695
+ // happens at the loop top.
696
+ if (signal.aborted && !modelSucceeded) {
697
+ yield* emitCancellation<TOutput>(runEnv);
698
+ return yield* finishRun(state, finalSnapshot);
699
+ }
700
+
701
+ if (!modelSucceeded) {
702
+ yield {
703
+ type: 'agent.error',
704
+ error: {
705
+ message: 'all configured providers failed without finishing',
706
+ code: 'no-provider-completed',
707
+ },
708
+ };
709
+ state.status = 'failed';
710
+ state.error = { message: 'no provider completed', code: 'no-provider-completed' };
711
+ return yield* finishRun(state, finalSnapshot);
712
+ }
713
+
714
+ usageAcc.add(lastModelId, stepUsage);
715
+ addModelUsage(state, lastModelId, stepUsage);
716
+ accumulateUsage(state.usage, stepUsage);
717
+
718
+ // Lateral-leak commit gate (RB-55 / AG-10 / C6): scan, commit
719
+ // the assistant message, record the taint span, and emit the
720
+ // detection event (see `runtime/run-gates.ts`).
721
+ const leakBlocked = yield* commitAssistantMessage<TOutput>(
722
+ runEnv,
723
+ textBuffer,
724
+ stepReasoningParts,
725
+ finalCalls,
726
+ reasoningPolicy,
727
+ );
728
+
729
+ const handoffCalls = finalCalls.filter((c) => handoffMap.has(c.toolName));
730
+ if (handoffCalls.length > 1) {
731
+ throw new MultipleHandoffsInStepError(handoffCalls.map((c) => c.toolName));
732
+ }
733
+
734
+ const stepRecord: RunStep = {
735
+ stepNumber,
736
+ startedAt: stepStart,
737
+ endedAt: new Date().toISOString(),
738
+ usage: stepUsage,
739
+ toolCalls: [],
740
+ agentId: state.currentAgentId,
741
+ // C3: journal the RAW model response (pre-leak-block text) so
742
+ // createReplayProvider(state) can re-drive the run offline.
743
+ ...(config.recordProviderResponses === true
744
+ ? {
745
+ providerResponse: {
746
+ modelId: lastModelId,
747
+ ...(textBuffer.length > 0 ? { text: textBuffer } : {}),
748
+ ...(finalCalls.length > 0 ? { toolCalls: [...finalCalls] } : {}),
749
+ },
750
+ }
751
+ : {}),
752
+ };
753
+ state.steps.push(stepRecord);
754
+ lastStepCalledToolNames = finalCalls.map((c) => c.toolName);
755
+
756
+ if (textBuffer.length > 0 && !leakBlocked) {
757
+ finalSnapshot.output = textBuffer as unknown as TOutput;
758
+ yield { type: 'text.complete', text: textBuffer };
759
+ }
760
+
761
+ if (finalCalls.length > 0) {
762
+ // `stepRegistry` / `stepExecutor` were resolved with the
763
+ // catalogue above (so the advertised tools and the executor's
764
+ // resolvable tools agree, including any `prepareStep` override).
765
+ const execRunContext: RunContext<TDeps> = {
766
+ ...runContextBase,
767
+ stepNumber,
768
+ messages,
769
+ ...(currentStepSpan !== undefined ? { span: currentStepSpan } : {}),
770
+ };
771
+
772
+ // Walk the calls (batch dispatch, inline handoff, approval
773
+ // pre-screen, once-per-step durable-HITL suspend; see
774
+ // `runtime/tool-call-walk.ts`).
775
+ const walked = yield* processStepToolCalls<TDeps, TOutput>(
776
+ runEnv,
777
+ finalCalls,
778
+ stepRegistry,
779
+ stepExecutor,
780
+ execRunContext,
781
+ stepNumber,
782
+ );
783
+ if (walked.suspended) {
784
+ if (walked.abortPending === true) {
785
+ // W-038: the abort raced the suspend - apply the
786
+ // `onPendingApprovals` policy to the just-collected
787
+ // approvals, then persist the FINAL policy-consistent
788
+ // state as the last checkpoint (the walk skipped its
789
+ // 'suspended' put so no stale awaiting_approval trail
790
+ // outlives the aborted outcome).
791
+ yield* emitCancellation<TOutput>(runEnv);
792
+ if (config.checkpointStore !== undefined) {
793
+ await config.checkpointStore.put(
794
+ state.id,
795
+ 'agent',
796
+ {
797
+ id: state.id,
798
+ threadId: state.id,
799
+ namespace: 'agent',
800
+ state: serializeRunState(state, { stripTracingApiKey: true }),
801
+ channelVersions: {},
802
+ stepNumber,
803
+ createdAt: new Date().toISOString(),
804
+ },
805
+ {
806
+ source: 'sync',
807
+ status: state.status === 'failed' ? 'failed' : 'aborted',
808
+ nodeName: 'agent.abort',
809
+ sessionId: state.sessionId,
810
+ },
811
+ );
812
+ }
813
+ }
814
+ return yield* finishRun(state, finalSnapshot);
815
+ }
816
+ }
817
+
818
+ currentStepSpan?.setAttributes({
819
+ 'gen_ai.usage.input_tokens': stepUsage.promptTokens,
820
+ 'gen_ai.usage.output_tokens': stepUsage.completionTokens,
821
+ });
822
+ currentStepSpan?.end();
823
+ currentStepSpan = undefined;
824
+ yield { type: 'step.end', stepNumber, usage: stepUsage };
825
+
826
+ if (finalCalls.length === 0) {
827
+ // C3: verifier gate on the terminal response (see
828
+ // `runtime/run-gates.ts`); a failed round feeds back and the
829
+ // loop takes another step while rounds remain.
830
+ if (config.verifiers !== undefined && config.verifiers.length > 0) {
831
+ const gate = yield* runVerifierGate<TDeps, TOutput>(
832
+ runEnv,
833
+ finalSnapshot,
834
+ stepNumber,
835
+ verifierRoundsUsed,
836
+ );
837
+ verifierRoundsUsed = gate.verifierRoundsUsed;
838
+ if (gate.continueRun) continue;
839
+ }
840
+ state.status = 'completed';
841
+ break;
842
+ }
843
+ }
844
+ } catch (cause) {
845
+ const message = cause instanceof Error ? cause.message : String(cause);
846
+ const code = cause instanceof AgentRuntimeError ? (cause.code as string) : 'unknown';
847
+ yield { type: 'agent.error', error: { message, code } };
848
+ state.status = 'failed';
849
+ state.error = { message, code };
850
+ return yield* finishRun(state, finalSnapshot);
851
+ } finally {
852
+ // C7: close the trace tree on every exit path.
853
+ currentStepSpan?.end();
854
+ currentStepSpan = undefined;
855
+ runSpan.setAttributes({
856
+ 'gen_ai.usage.input_tokens': state.usage.promptTokens,
857
+ 'gen_ai.usage.output_tokens': state.usage.completionTokens,
858
+ 'graphorin.run.status': state.status,
859
+ });
860
+ runSpan.setStatus(state.status === 'failed' ? 'error' : 'ok');
861
+ runSpan.end();
862
+ // AG-5: drop the parent-signal listener so it does not accumulate across
863
+ // runs that share one long-lived `options.signal`. Runs after this point
864
+ // (the follow-up loop) keep working via `agent.abort()` on `localCtl`.
865
+ if (parentSignal !== undefined) {
866
+ parentSignal.removeEventListener('abort', onParentAbort);
867
+ }
868
+ }
869
+
870
+ // Terminal output phases in their frozen order: stop-condition cut
871
+ // (AG-24), structured-output parse (AG-3), output guardrails
872
+ // (AG-2 / SDF-4) - see `runtime/run-gates.ts`.
873
+ yield* finalizeRunOutput<TDeps, TOutput>(runEnv, finalSnapshot);
874
+ activeRunState = undefined;
875
+ return yield* finishRun(state, finalSnapshot);
876
+ }
877
+
878
+ // Terminal path: settle the manual-compact queue, finalize the
879
+ // result, clear terminal-run spill artifacts and emit `agent.end`
880
+ // (see `runtime/run-finish.ts`).
881
+ const finishRunBase = createRunFinisher<TOutput>({
882
+ pendingManualCompacts,
883
+ spillWriter,
884
+ checkpointStore: config.checkpointStore,
885
+ checkpointPolicy: config.checkpointPolicy,
886
+ });
887
+
888
+ // The public call surface over the run loop (AG-9; see
889
+ // `runtime/agent-surface.ts`).
890
+ const { stream, run } = createRunMethods<TDeps, TOutput>(runLoop);
891
+
892
+ const steer = (message: AgentInput): void => {
893
+ const { seed } = asMessages(message);
894
+ pendingSteer.push(...seed);
895
+ if (activeRunState !== undefined) {
896
+ externalEventQueue.push({
897
+ type: 'agent.steered',
898
+ runId: activeRunState.id,
899
+ } as AgentEvent<TOutput>);
900
+ }
901
+ };
902
+
903
+ const followUp = (message: AgentInput): void => {
904
+ const { seed } = asMessages(message);
905
+ pendingFollowUp.push(...seed);
906
+ if (activeRunState !== undefined) {
907
+ externalEventQueue.push({
908
+ type: 'agent.followup.queued',
909
+ runId: activeRunState.id,
910
+ } as AgentEvent<TOutput>);
911
+ }
912
+ };
913
+
914
+ const abort = (options?: AbortOptions): void => {
915
+ pendingAbort = options ?? {};
916
+ abortController?.abort();
917
+ };
918
+
919
+ // `agent.toTool()` - the sub-agent tool surface, incl. contextFold
920
+ // and child-taint propagation (see `runtime/agent-to-tool.ts`).
921
+ const toTool = createToTool<TDeps, TOutput>({ config, run, stream });
922
+
923
+ // `compact` / `fanOut` / the progress IO surface (AG-13 / AG-7 /
924
+ // AG-20; see `runtime/agent-surface.ts`). The deps object reads the
925
+ // factory's mutable `activeRunState` scratch through a live getter.
926
+ const surfaceDeps = {
927
+ config,
928
+ memory,
929
+ agentId,
930
+ getActiveRunState: () => activeRunState,
931
+ externalEventQueue,
932
+ pendingManualCompacts,
933
+ progressIO,
934
+ };
935
+ const compact = createCompactMethod<TDeps, TOutput>(surfaceDeps);
936
+ const fanOut = createFanOutMethod<TDeps, TOutput>(surfaceDeps);
937
+ const progress: AgentProgressIO = createProgressSurface<TDeps, TOutput>(surfaceDeps);
938
+
939
+ void config.sensitivity as Sensitivity | undefined;
940
+
941
+ const agent: Agent<TDeps, TOutput> = {
942
+ id: agentId,
943
+ config,
944
+ stream,
945
+ run,
946
+ steer,
947
+ followUp,
948
+ abort,
949
+ toTool,
950
+ compact,
951
+ fanOut,
952
+ progress,
953
+ registry: toolRegistry,
954
+ };
955
+
956
+ return agent;
957
+ }