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