@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,419 @@
1
+ /**
2
+ * The run-level gates the agent loop applies to its inputs and outputs:
3
+ * input / output guardrail screening (AG-2 / SDF-4), the structured
4
+ * output contract (AG-3: the JSON-only instruction, fence stripping,
5
+ * parse + validation), the lateral-leak commit gate on outgoing
6
+ * assistant content (RB-55 / AG-10), the verifier gate on terminal
7
+ * responses (C3), and the shared cancellation path (AG-6). Extracted
8
+ * verbatim from `factory.ts` (issue #23).
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ import { createHash } from 'node:crypto';
14
+ import type {
15
+ AgentEvent,
16
+ AssistantMessage,
17
+ Message,
18
+ ReasoningContent,
19
+ ReasoningRetention,
20
+ RunState,
21
+ ToolCall,
22
+ } from '@graphorin/core';
23
+ import { composeGuardrails, type InputGuardrail } from '@graphorin/security/guardrails';
24
+ import type { CausalityMonitor } from '../lateral-leak/causality-monitor.js';
25
+ import type { buildDataFlowGuard } from '../tooling/dataflow.js';
26
+ import type { AbortOptions, AgentConfig, VerifierResult } from '../types.js';
27
+ import { buildAssistantMessage } from './messages.js';
28
+ import type { InternalRunSnapshot, MutableRunState } from './run-input.js';
29
+
30
+ const sha256Hex = (input: string): string =>
31
+ createHash('sha256').update(input, 'utf8').digest('hex');
32
+
33
+ /**
34
+ * Replacement content committed in place of assistant commentary the
35
+ * causality monitor blocked (AG-10). Worded to not match any built-in
36
+ * denial pattern, so the notice itself never re-triggers the monitor.
37
+ */
38
+ export const LATERAL_LEAK_BLOCKED_NOTICE =
39
+ '[graphorin] assistant commentary withheld by the lateral-leak defense.';
40
+
41
+ /**
42
+ * Strip a single Markdown code fence around a JSON payload (AG-3).
43
+ * Models often wrap structured output in ```json fences even when told
44
+ * not to. ReDoS-safe: the info string is matched with `[^\n]*`.
45
+ */
46
+ export function stripJsonFence(text: string): string {
47
+ const trimmed = text.trim();
48
+ const match = /^```[^\n]*\n([\s\S]*?)\n?```$/.exec(trimmed);
49
+ return match?.[1] ?? trimmed;
50
+ }
51
+
52
+ /**
53
+ * The AG-3 fallback instruction: one trailing system message that
54
+ * pins JSON-only output and embeds the wire schema / description.
55
+ * This is the documented structured-output contract for adapters that
56
+ * do not yet consume `ProviderRequest.outputType` natively (PS-24).
57
+ */
58
+ export function buildStructuredInstruction(spec: {
59
+ readonly description?: string;
60
+ readonly jsonSchema?: Readonly<Record<string, unknown>>;
61
+ }): string {
62
+ const parts = [
63
+ 'Respond with a single valid JSON value only - no prose, no Markdown code fences.',
64
+ ];
65
+ if (spec.description !== undefined && spec.description.length > 0) {
66
+ parts.push(spec.description);
67
+ }
68
+ if (spec.jsonSchema !== undefined) {
69
+ parts.push(`The JSON MUST conform to this JSON Schema:\n${JSON.stringify(spec.jsonSchema)}`);
70
+ }
71
+ return parts.join('\n');
72
+ }
73
+
74
+ export { sha256Hex };
75
+
76
+ /** The run-scoped context the cancellation path operates on. */
77
+ export interface CancellationEnv {
78
+ readonly state: MutableRunState & RunState;
79
+ readonly messages: Message[];
80
+ readonly getPendingAbort: () => AbortOptions | undefined;
81
+ }
82
+
83
+ /**
84
+ * AG-6: shared cancellation path for the loop-top abort check, a
85
+ * mid-stream provider abort, and the abort-during-suspend seam (W-038).
86
+ * Yields `agent.cancelling`, applies the `onPendingApprovals` policy,
87
+ * and returns `true` when the run was finalized as 'failed' (the 'fail'
88
+ * policy WITH live approvals - the caller must `return finalize(...)`);
89
+ * otherwise it sets `state.status = 'aborted'` and returns `false`.
90
+ *
91
+ * Policy semantics (W-038):
92
+ * - `'deny'`: drain every pending approval AND commit a matching tool
93
+ * message per drained toolCallId - the transcript must not keep a
94
+ * dangling `tool_use` real providers reject on a later resume.
95
+ * - `'hold'`: keep `pendingApprovals` on the aborted state; resume is
96
+ * possible only through an explicit directive.
97
+ * - `'fail'`: fail the run ONLY when approvals are actually pending -
98
+ * an abort with an empty queue is a plain 'aborted', per the
99
+ * documented contract.
100
+ */
101
+ export async function* emitCancellation<TOutput>(
102
+ env: CancellationEnv,
103
+ ): AsyncGenerator<AgentEvent<TOutput>, boolean, void> {
104
+ const { state, messages, getPendingAbort } = env;
105
+ yield {
106
+ type: 'agent.cancelling',
107
+ runId: state.id,
108
+ drain: getPendingAbort()?.drain ?? false,
109
+ onPendingApprovals: getPendingAbort()?.onPendingApprovals ?? 'deny',
110
+ };
111
+ const policy = getPendingAbort()?.onPendingApprovals ?? 'deny';
112
+ if (policy === 'deny') {
113
+ const drained = state.pendingApprovals.splice(0, state.pendingApprovals.length);
114
+ for (const approval of drained) {
115
+ yield {
116
+ type: 'tool.approval.denied',
117
+ toolCallId: approval.toolCallId,
118
+ reason: 'auto-denied: agent.abort()',
119
+ };
120
+ const text = 'Error: tool approval denied: auto-denied on abort';
121
+ messages.push({ role: 'tool', toolCallId: approval.toolCallId, content: text });
122
+ state.messages.push({ role: 'tool', toolCallId: approval.toolCallId, content: text });
123
+ }
124
+ } else if (policy === 'fail' && state.pendingApprovals.length > 0) {
125
+ state.status = 'failed';
126
+ state.error = { message: 'aborted with pending approvals', code: 'run-aborted' };
127
+ yield {
128
+ type: 'agent.error',
129
+ error: { message: 'aborted with pending approvals', code: 'run-aborted' },
130
+ };
131
+ return true;
132
+ }
133
+ state.status = 'aborted';
134
+ return false;
135
+ }
136
+
137
+ /** The run-scoped context the input-guardrail screen operates on. */
138
+ export interface GuardrailScreenEnv {
139
+ readonly state: MutableRunState & RunState;
140
+ readonly messages: Message[];
141
+ readonly sessionId: string;
142
+ readonly agentId: string;
143
+ }
144
+
145
+ /**
146
+ * AG-2 / SDF-4: input guardrails screen each fresh-run seed user
147
+ * message (string content) BEFORE the first provider call, using the
148
+ * canonical `@graphorin/security` composer. 'block' fails the run
149
+ * without reaching the model; 'rewrite' replaces the content in both
150
+ * the working buffer and the persisted RunState; 'warn' logs and
151
+ * continues. Resumed runs skip the pass - their seed was screened
152
+ * when first submitted.
153
+ *
154
+ * Returns `true` when a guardrail blocked the run (the failure is
155
+ * already recorded on `state` and streamed - the loop must finish).
156
+ */
157
+ export async function* screenInputGuardrails<TOutput>(
158
+ env: GuardrailScreenEnv,
159
+ inputGuards: ReadonlyArray<InputGuardrail<string>>,
160
+ ): AsyncGenerator<AgentEvent<TOutput>, boolean, void> {
161
+ const { state, messages, sessionId, agentId } = env;
162
+ for (let i = 0; i < messages.length; i++) {
163
+ const msg = messages[i];
164
+ if (msg === undefined || msg.role !== 'user' || typeof msg.content !== 'string') continue;
165
+ const composed = await composeGuardrails(inputGuards, msg.content, {
166
+ stage: 'input',
167
+ runId: state.id,
168
+ sessionId,
169
+ agentId,
170
+ });
171
+ if (!composed.ok) {
172
+ yield {
173
+ type: 'guardrail.tripped',
174
+ guardrailName: composed.name,
175
+ phase: 'input',
176
+ reason: composed.message,
177
+ };
178
+ const message = `input guardrail '${composed.name}' blocked the run: ${composed.message}`;
179
+ yield { type: 'agent.error', error: { message, code: 'guardrail-blocked' } };
180
+ state.status = 'failed';
181
+ state.error = { message, code: 'guardrail-blocked' };
182
+ return true;
183
+ }
184
+ if (composed.value !== msg.content) {
185
+ const rewritten: Message = { ...msg, content: composed.value };
186
+ const stateIdx = state.messages.indexOf(msg);
187
+ messages[i] = rewritten;
188
+ if (stateIdx !== -1) state.messages[stateIdx] = rewritten;
189
+ }
190
+ }
191
+ return false;
192
+ }
193
+
194
+ /** The run-scoped context the assistant-commit gate operates on. */
195
+ export interface AssistantCommitEnv {
196
+ readonly state: MutableRunState & RunState;
197
+ readonly messages: Message[];
198
+ readonly sessionId: string;
199
+ readonly agentId: string;
200
+ readonly causalityMonitor: CausalityMonitor | undefined;
201
+ readonly toolDataFlowGuard: ReturnType<typeof buildDataFlowGuard> | undefined;
202
+ }
203
+
204
+ /**
205
+ * Lateral-leak (RB-55 / AG-10): scan the outgoing assistant
206
+ * content BEFORE it is appended, so a 'block' decision keeps
207
+ * the laundered commentary out of the durable history - and
208
+ * therefore out of every subsequent provider request. The
209
+ * deltas already streamed; what 'block' protects is the
210
+ * persistent buffer and the run's final output.
211
+ *
212
+ * Commits the (possibly replaced) assistant message to both buffers,
213
+ * records the C6 taint span, and emits `agent.lateral-leak.detected`
214
+ * when the monitor tripped. Returns `leakBlocked`.
215
+ */
216
+ export async function* commitAssistantMessage<TOutput>(
217
+ env: AssistantCommitEnv,
218
+ textBuffer: string,
219
+ stepReasoningParts: ReadonlyArray<ReasoningContent>,
220
+ finalCalls: ReadonlyArray<ToolCall>,
221
+ reasoningPolicy: ReasoningRetention,
222
+ ): AsyncGenerator<AgentEvent<TOutput>, boolean, void> {
223
+ const { state, messages, sessionId, agentId, causalityMonitor, toolDataFlowGuard } = env;
224
+ const leakCheck =
225
+ causalityMonitor !== undefined && textBuffer.length > 0
226
+ ? causalityMonitor.checkMessage(textBuffer)
227
+ : undefined;
228
+ const leakBlocked = leakCheck?.leakDetected === true && leakCheck.decision === 'block';
229
+
230
+ const assistant: AssistantMessage = buildAssistantMessage(
231
+ leakBlocked ? LATERAL_LEAK_BLOCKED_NOTICE : textBuffer,
232
+ stepReasoningParts,
233
+ finalCalls,
234
+ agentId,
235
+ reasoningPolicy,
236
+ );
237
+ messages.push(assistant);
238
+ state.messages.push(assistant);
239
+
240
+ // C6: once the run is tainted, the model's own TEXT output is
241
+ // derived from untrusted context - record it so a later sink call
242
+ // copying the model's phrasing still trips the verbatim probe
243
+ // (no-op on untainted runs). Tool-call args are deliberately NOT
244
+ // recorded: the sink gate inspects those same args next, and
245
+ // recording them first would self-match every post-taint call,
246
+ // collapsing the precise verbatim signal into the coarse one.
247
+ if (toolDataFlowGuard !== undefined && textBuffer.length > 0 && !leakBlocked) {
248
+ toolDataFlowGuard.recordAssistant(state.id, textBuffer);
249
+ }
250
+
251
+ if (leakCheck?.leakDetected === true) {
252
+ const sha = sha256Hex(textBuffer);
253
+ yield {
254
+ type: 'agent.lateral-leak.detected',
255
+ runId: state.id,
256
+ sessionId,
257
+ agentId,
258
+ vector: leakCheck.vector,
259
+ severity: leakCheck.severity,
260
+ causalityChain: leakCheck.causalityChain,
261
+ messageContentSha256: sha,
262
+ ...(leakCheck.matchedPattern !== undefined
263
+ ? { matchedPattern: leakCheck.matchedPattern }
264
+ : {}),
265
+ decision: leakCheck.decision,
266
+ detectedAtIso: new Date().toISOString(),
267
+ };
268
+ }
269
+ return leakBlocked;
270
+ }
271
+
272
+ /** The run-scoped context the verifier gate operates on. */
273
+ export interface VerifierGateEnv<TDeps, TOutput> {
274
+ readonly config: Pick<AgentConfig<TDeps, TOutput>, 'verifiers' | 'maxVerifierRounds'>;
275
+ readonly state: MutableRunState & RunState;
276
+ readonly messages: Message[];
277
+ }
278
+
279
+ /** Outcome of one verifier gate pass (read by the run loop). */
280
+ export interface VerifierGateResult {
281
+ /** `true`: feedback was fed back - the loop takes another step. */
282
+ readonly continueRun: boolean;
283
+ /** The continuation rounds consumed so far (threaded back to the loop). */
284
+ readonly verifierRoundsUsed: number;
285
+ }
286
+
287
+ /**
288
+ * C3: verifier seam - deterministic checks run on EVERY terminal
289
+ * response (so the outcome is always observable via
290
+ * verifier.result events). Failures feed structured feedback back
291
+ * as a user message and the loop continues, but only while
292
+ * continuation rounds remain (maxVerifierRounds, default 1);
293
+ * exhausted rounds complete with the last output. A verifier that
294
+ * throws is treated as passed (a buggy verifier must not fail the
295
+ * run).
296
+ */
297
+ export async function* runVerifierGate<TDeps, TOutput>(
298
+ env: VerifierGateEnv<TDeps, TOutput>,
299
+ finalSnapshot: InternalRunSnapshot<TOutput>,
300
+ stepNumber: number,
301
+ verifierRoundsUsed: number,
302
+ ): AsyncGenerator<AgentEvent<TOutput>, VerifierGateResult, void> {
303
+ const { config, state, messages } = env;
304
+ if (config.verifiers !== undefined && config.verifiers.length > 0) {
305
+ const feedbacks: string[] = [];
306
+ for (const verifier of config.verifiers) {
307
+ let result: VerifierResult;
308
+ try {
309
+ result = await verifier.verify({
310
+ output: String(finalSnapshot.output ?? ''),
311
+ state,
312
+ stepNumber,
313
+ });
314
+ } catch {
315
+ result = { ok: true };
316
+ }
317
+ yield {
318
+ type: 'verifier.result',
319
+ verifierId: verifier.id,
320
+ ok: result.ok,
321
+ ...(result.ok ? {} : { feedback: result.feedback }),
322
+ stepNumber,
323
+ };
324
+ if (!result.ok) feedbacks.push(`[verifier:${verifier.id}] ${result.feedback}`);
325
+ }
326
+ if (feedbacks.length > 0 && verifierRoundsUsed < (config.maxVerifierRounds ?? 1)) {
327
+ const feedbackMessage: Message = {
328
+ role: 'user',
329
+ content: `Your response failed ${feedbacks.length} verification check(s). Address the feedback and answer again:\n${feedbacks.join('\n')}`,
330
+ };
331
+ messages.push(feedbackMessage);
332
+ state.messages.push(feedbackMessage);
333
+ return { continueRun: true, verifierRoundsUsed: verifierRoundsUsed + 1 };
334
+ }
335
+ }
336
+ return { continueRun: false, verifierRoundsUsed };
337
+ }
338
+
339
+ /** The run-scoped context the terminal output phases operate on. */
340
+ export interface RunOutputEnv<TDeps, TOutput> {
341
+ readonly config: AgentConfig<TDeps, TOutput>;
342
+ readonly state: MutableRunState & RunState;
343
+ readonly sessionId: string;
344
+ readonly agentId: string;
345
+ readonly stopWhen: { readonly description: string };
346
+ }
347
+
348
+ /**
349
+ * The terminal output phases of a run, in their frozen order: the AG-24
350
+ * stop-condition cut check, the AG-3 structured-output parse +
351
+ * validation (before output guardrails, so they screen the PARSED
352
+ * value), and the AG-2 / SDF-4 output guardrail screen. Mutates
353
+ * `state` / `finalSnapshot` exactly as the inline blocks did.
354
+ */
355
+ export async function* finalizeRunOutput<TDeps, TOutput>(
356
+ env: RunOutputEnv<TDeps, TOutput>,
357
+ finalSnapshot: InternalRunSnapshot<TOutput>,
358
+ ): AsyncGenerator<AgentEvent<TOutput>, void, void> {
359
+ const { config, state, sessionId, agentId, stopWhen } = env;
360
+ if (state.status === 'running') {
361
+ // AG-24: the loop exited via the stop condition (default
362
+ // isStepCount(50)) with work still pending - that is a CUT run,
363
+ // not a completion. Surface it as a typed failure so consumers
364
+ // can tell it apart from a clean finish.
365
+ const message = `run stopped by stop condition: ${stopWhen.description}`;
366
+ state.status = 'failed';
367
+ state.error = { message, code: 'stop-condition' };
368
+ yield { type: 'agent.error', error: { message, code: 'stop-condition' } };
369
+ }
370
+ // AG-3: structured output is parsed + validated on the completed
371
+ // path - a failure is a typed run failure (`output-validation-failed`),
372
+ // never a silent text-cast. Runs BEFORE output guardrails so they
373
+ // screen the PARSED value.
374
+ if (state.status === 'completed' && config.outputType?.kind === 'structured') {
375
+ const raw = String(finalSnapshot.output ?? '');
376
+ try {
377
+ const parsed: unknown = JSON.parse(stripJsonFence(raw));
378
+ finalSnapshot.output = (
379
+ config.outputType.schema !== undefined ? config.outputType.schema.parse(parsed) : parsed
380
+ ) as TOutput;
381
+ } catch (cause) {
382
+ const message = `structured output validation failed: ${
383
+ cause instanceof Error ? cause.message : String(cause)
384
+ }`;
385
+ yield { type: 'agent.error', error: { message, code: 'output-validation-failed' } };
386
+ state.status = 'failed';
387
+ state.error = { message, code: 'output-validation-failed' };
388
+ }
389
+ }
390
+
391
+ // AG-2 / SDF-4: output guardrails screen the final output on the
392
+ // completed path before `agent.end`. 'block' fails the run;
393
+ // 'rewrite' replaces the durable result (`result.output`) - the
394
+ // text deltas were already streamed, so the rewrite governs what
395
+ // is persisted/returned, not the live token stream.
396
+ const outputGuards = config.guardrails?.output;
397
+ if (state.status === 'completed' && outputGuards !== undefined && outputGuards.length > 0) {
398
+ const composed = await composeGuardrails(outputGuards, finalSnapshot.output, {
399
+ stage: 'output',
400
+ runId: state.id,
401
+ sessionId,
402
+ agentId,
403
+ });
404
+ if (!composed.ok) {
405
+ yield {
406
+ type: 'guardrail.tripped',
407
+ guardrailName: composed.name,
408
+ phase: 'output',
409
+ reason: composed.message,
410
+ };
411
+ const message = `output guardrail '${composed.name}' blocked the run: ${composed.message}`;
412
+ yield { type: 'agent.error', error: { message, code: 'guardrail-blocked' } };
413
+ state.status = 'failed';
414
+ state.error = { message, code: 'guardrail-blocked' };
415
+ } else if (composed.value !== finalSnapshot.output) {
416
+ finalSnapshot.output = composed.value;
417
+ }
418
+ }
419
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Fresh-run message-buffer initialization: resolve the agent's
3
+ * `instructions` (string or per-run function form, AG-8), optionally
4
+ * assemble the memory-aware 6-layer system prompt via the context
5
+ * engine (CE-1, opt-in), and seed the buffer + RunState mirror.
6
+ * Extracted verbatim from `factory.ts` (issue #23).
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ import type { Message, RunContext, RunState } from '@graphorin/core';
12
+ import type { Memory } from '@graphorin/memory';
13
+ import { newId } from '../internal/ids.js';
14
+ import { createInitialRunState } from '../run-state/index.js';
15
+ import type { buildDataFlowGuard } from '../tooling/dataflow.js';
16
+ import type { AgentCallOptions, AgentConfig } from '../types.js';
17
+ import { lastUserText } from './messages.js';
18
+ import type { MutableRunState } from './run-input.js';
19
+
20
+ /** What the fresh-run seeding pass needs from the run loop's scope. */
21
+ export interface RunInitEnv<TDeps, TOutput> {
22
+ readonly config: AgentConfig<TDeps, TOutput>;
23
+ readonly options: AgentCallOptions<TDeps>;
24
+ readonly memory: Memory | undefined;
25
+ readonly agentId: string;
26
+ readonly sessionId: string;
27
+ readonly userId: string | undefined;
28
+ readonly tracer: RunContext<TDeps>['tracer'];
29
+ readonly signal: AbortSignal;
30
+ readonly usageAcc: RunContext<TDeps>['usage'];
31
+ readonly state: MutableRunState & RunState;
32
+ }
33
+
34
+ /**
35
+ * Inject the agent's system prompt at the top of the buffer
36
+ * exactly once per run, before any seed messages - then mirror the
37
+ * assembled messages into RunState so the JSONL session export and
38
+ * any downstream consumers see what the agent saw.
39
+ */
40
+ export async function seedInitialMessages<TDeps, TOutput>(
41
+ env: RunInitEnv<TDeps, TOutput>,
42
+ messages: Message[],
43
+ seed: Message[],
44
+ ): Promise<void> {
45
+ const { config, options, memory, agentId, sessionId, userId } = env;
46
+ const { tracer, signal, usageAcc, state } = env;
47
+ const instructionsRaw = config.instructions;
48
+ // AG-8: resolve the function form of `instructions` (sync or async). It is
49
+ // resolved ONCE per run (the per-run contract documented on `AgentConfig`),
50
+ // against a RunContext snapshot at step 0; the result is pinned as the
51
+ // run's system-prompt prefix. A function that previously returned nothing
52
+ // observable now actually seeds the system message.
53
+ let instructionsText: string;
54
+ if (typeof instructionsRaw === 'string') {
55
+ instructionsText = instructionsRaw;
56
+ } else {
57
+ const instructionsCtx: RunContext<TDeps> = {
58
+ runId: state.id,
59
+ sessionId,
60
+ ...(userId !== undefined ? { userId } : {}),
61
+ agentId,
62
+ deps: (options.deps ?? config.deps) as TDeps,
63
+ tracer,
64
+ signal,
65
+ usage: usageAcc,
66
+ stepNumber: 0,
67
+ messages,
68
+ state,
69
+ };
70
+ instructionsText = await instructionsRaw(instructionsCtx);
71
+ }
72
+ let systemPrompt = instructionsText;
73
+ if (config.autoAssembleContext === true && memory !== undefined) {
74
+ // CE-1 (opt-in): build the memory-aware 6-layer system prompt via the
75
+ // context engine. The instructions become Layer 2; the engine prepends
76
+ // the memory base and appends working blocks, procedural rules, skill
77
+ // cards, the metadata counts, and (when `factsAutoRecall` is configured)
78
+ // auto-recalled facts. Default-off keeps the explicit memory-tools
79
+ // pattern, so the system prompt is `instructions` alone.
80
+ const lastUser = lastUserText(seed);
81
+ const assembled = await memory.contextEngine.assemble(memory, {
82
+ scope: { userId: userId ?? agentId, sessionId, agentId },
83
+ agentId,
84
+ sessionId,
85
+ runId: state.id,
86
+ ...(instructionsText.length > 0 ? { agentInstructions: instructionsText } : {}),
87
+ ...(lastUser !== undefined ? { lastUserMessage: lastUser } : {}),
88
+ });
89
+ systemPrompt = assembled.systemMessage.content;
90
+ }
91
+ if (systemPrompt.length > 0) {
92
+ messages.push({ role: 'system', content: systemPrompt });
93
+ }
94
+ messages.push(...seed);
95
+ // Mirror the assembled messages into RunState so the JSONL
96
+ // session export and any downstream consumers see what the
97
+ // agent saw.
98
+ for (const m of messages) state.messages.push(m);
99
+ }
100
+
101
+ /** What the run-state bootstrap needs from the run loop's scope. */
102
+ export interface RunStateInitEnv<TDeps, TOutput> {
103
+ readonly config: Pick<AgentConfig<TDeps, TOutput>, 'toolPromotion'>;
104
+ readonly agentId: string;
105
+ readonly sessionId: string;
106
+ readonly userId: string | undefined;
107
+ readonly toolDataFlowGuard: ReturnType<typeof buildDataFlowGuard> | undefined;
108
+ }
109
+
110
+ /** The run-scoped state the bootstrap hands back to the run loop. */
111
+ export interface InitializedRunState {
112
+ readonly state: MutableRunState & RunState;
113
+ readonly promotedDeferred: Set<string>;
114
+ readonly runStartPromotions: Set<string> | undefined;
115
+ }
116
+
117
+ /**
118
+ * Bootstrap the run's state: create (or adopt) the {@link RunState},
119
+ * rehydrate the run-scoped security state (AG-19: the persisted coarse
120
+ * taint summary re-seeds the enforce-mode sink gate), and restore the
121
+ * `tool_search` promotion set (TL-7 / AG-19, with the C1
122
+ * `'run-boundary'` snapshot).
123
+ */
124
+ export function initializeRunState<TDeps, TOutput>(
125
+ env: RunStateInitEnv<TDeps, TOutput>,
126
+ resumed: RunState | undefined,
127
+ ): InitializedRunState {
128
+ const { config, agentId, sessionId, userId, toolDataFlowGuard } = env;
129
+ const baseState: RunState = resumed
130
+ ? resumed
131
+ : createInitialRunState({
132
+ id: newId('run'),
133
+ agentId,
134
+ sessionId,
135
+ ...(userId !== undefined ? { userId } : {}),
136
+ });
137
+ // Mutable view (the public RunState is `readonly` but the runtime
138
+ // owns the lifecycle; cast to a writable shape here).
139
+ const state = baseState as RunState as unknown as MutableRunState & RunState;
140
+
141
+ // AG-19: rehydrate the run-scoped security state BEFORE any tool runs this
142
+ // resume. Seeding the data-flow ledger with the persisted coarse taint
143
+ // summary keeps an enforce-mode sink gated across the suspend/resume
144
+ // boundary (the promoted-tool set is restored below, once it exists).
145
+ if (resumed && state.taintSummary !== undefined) {
146
+ toolDataFlowGuard?.seedLedger(state.id, state.taintSummary);
147
+ }
148
+
149
+ // WI-05: deferred tools promoted by a `tool_search` call this run.
150
+ // Membership grows as the model discovers tools and gates which
151
+ // deferred entries the per-step catalogue advertises. TL-7/AG-19:
152
+ // persisted onto `RunState.promotedTools` at every exit and
153
+ // rehydrated here, so a resumed run keeps its discoveries.
154
+ const promotedDeferred = new Set<string>();
155
+ // AG-19: restore deferred tools promoted by `tool_search` before the suspend
156
+ // so they remain in the per-step catalogue after a resume.
157
+ if (resumed && state.promotedTools !== undefined) {
158
+ for (const name of state.promotedTools) promotedDeferred.add(name);
159
+ }
160
+ // C1: under `toolPromotion: 'run-boundary'` the advertised catalogue is
161
+ // frozen to the promotions known at run start (incl. those restored
162
+ // above), keeping the provider prompt cache byte-stable for the whole
163
+ // run. New promotions still land in `promotedDeferred` (and persist),
164
+ // taking effect on the next run / resume.
165
+ const runStartPromotions =
166
+ config.toolPromotion === 'run-boundary' ? new Set(promotedDeferred) : undefined;
167
+
168
+ return { state, promotedDeferred, runStartPromotions };
169
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Run-input normalization and configuration validation guards for the
3
+ * agent runtime: the `AgentInput | RunState` -> seed-message mapping,
4
+ * the preferred-model / model-spec structural checks, and the runtime's
5
+ * internal mutable views of `RunState`. Extracted verbatim from
6
+ * `factory.ts` (issue #23).
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ import type {
12
+ HandoffRecord,
13
+ Message,
14
+ RunState,
15
+ RunStep,
16
+ ToolApproval,
17
+ Usage,
18
+ } from '@graphorin/core';
19
+ import { InvalidAgentConfigError, InvalidPreferredModelError } from '../errors/index.js';
20
+ import type { AgentInput } from '../types.js';
21
+
22
+ /**
23
+ * Internal mutable view of {@link RunState}. The public type marks
24
+ * most fields `readonly` to guard against accidental mutation by
25
+ * consumers; the runtime owns the lifecycle and writes through
26
+ * this view.
27
+ */
28
+ export interface MutableRunState {
29
+ status: RunState['status'];
30
+ currentAgentId: string;
31
+ readonly steps: RunStep[];
32
+ readonly messages: Message[];
33
+ readonly pendingApprovals: ToolApproval[];
34
+ readonly handoffs: HandoffRecord[];
35
+ usage: Usage;
36
+ error?: RunState['error'];
37
+ finishedAt?: string;
38
+ usageByModel?: RunState['usageByModel'];
39
+ /** W-001: parked sub-agent runs (see {@link RunState.pendingSubRuns}). */
40
+ pendingSubRuns?: RunState['pendingSubRuns'];
41
+ }
42
+
43
+ export interface InternalRunSnapshot<TOutput> {
44
+ output: TOutput;
45
+ }
46
+
47
+ export function isModelHintLike(value: unknown): boolean {
48
+ return value === 'fast' || value === 'balanced' || value === 'smart';
49
+ }
50
+
51
+ export function isModelSpecLike(value: unknown): boolean {
52
+ if (typeof value !== 'object' || value === null) return false;
53
+ const v = value as Record<string, unknown>;
54
+ if (typeof v.modelId === 'string' && typeof v.name === 'string') return true;
55
+ if (typeof v.provider === 'object' && v.provider !== null && typeof v.model === 'string') {
56
+ return true;
57
+ }
58
+ return false;
59
+ }
60
+
61
+ export function validatePreferredModel(value: unknown): void {
62
+ if (value === undefined) return;
63
+ if (isModelHintLike(value)) return;
64
+ if (isModelSpecLike(value)) return;
65
+ throw new InvalidPreferredModelError(value);
66
+ }
67
+
68
+ export function isMessageObject(value: unknown): value is Message {
69
+ if (typeof value !== 'object' || value === null) return false;
70
+ const role = (value as { role?: unknown }).role;
71
+ return role === 'system' || role === 'user' || role === 'assistant' || role === 'tool';
72
+ }
73
+
74
+ export function isRunStateObject(value: unknown): value is RunState {
75
+ if (typeof value !== 'object' || value === null) return false;
76
+ const v = value as Record<string, unknown>;
77
+ return (
78
+ typeof v.id === 'string' &&
79
+ typeof v.agentId === 'string' &&
80
+ Array.isArray(v.messages) &&
81
+ Array.isArray(v.steps)
82
+ );
83
+ }
84
+
85
+ export function asMessages(input: AgentInput | RunState): {
86
+ readonly seed: Message[];
87
+ readonly resumed?: RunState;
88
+ } {
89
+ if (typeof input === 'string') {
90
+ return { seed: [{ role: 'user', content: input }] };
91
+ }
92
+ if (Array.isArray(input)) {
93
+ return { seed: [...input] as Message[] };
94
+ }
95
+ if (isMessageObject(input)) {
96
+ return { seed: [input] };
97
+ }
98
+ if (isRunStateObject(input)) {
99
+ return { seed: [], resumed: input };
100
+ }
101
+ throw new InvalidAgentConfigError(`unrecognized AgentInput shape`);
102
+ }