@arnilo/prism 0.9.0 → 0.10.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 +24 -1
- package/README.md +13 -12
- package/dist/agent-approval.d.ts +7 -1
- package/dist/agent-approval.js +15 -6
- package/dist/agent-run-lifecycle.js +19 -5
- package/dist/agent-run-state.d.ts +26 -5
- package/dist/agent-run-state.js +97 -1
- package/dist/agent-session/event-subscriber.d.ts +2 -0
- package/dist/agent-session/event-subscriber.js +3 -0
- package/dist/agent-session/session/assemble.js +156 -9
- package/dist/agent-session/session/persist.js +11 -5
- package/dist/agent-session/session/provider-round.js +54 -13
- package/dist/agent-session/session/tool-round.d.ts +2 -2
- package/dist/agent-session/session/tool-round.js +58 -5
- package/dist/agent-session/session/types.d.ts +20 -2
- package/dist/agent-session/session.d.ts +65 -4
- package/dist/agent-session/session.js +156 -16
- package/dist/context-budget.d.ts +11 -0
- package/dist/context-budget.js +33 -2
- package/dist/contracts-core/agent.d.ts +26 -5
- package/dist/contracts-core/extensions.d.ts +3 -0
- package/dist/contracts-core/guardrail-packs.d.ts +8 -3
- package/dist/contracts-core/loop.d.ts +36 -0
- package/dist/contracts-core/provider.d.ts +6 -1
- package/dist/contracts-core/run-limits.d.ts +10 -1
- package/dist/contracts-protocol.d.ts +6 -4
- package/dist/contracts-run-state.d.ts +37 -3
- package/dist/contributions.d.ts +2 -1
- package/dist/contributions.js +1 -0
- package/dist/extensions.d.ts +15 -1
- package/dist/extensions.js +68 -0
- package/dist/guardrail-packs/types.d.ts +10 -0
- package/dist/guardrail-packs/validation-respect.js +16 -0
- package/dist/guardrails.d.ts +42 -1
- package/dist/guardrails.js +124 -15
- package/dist/index.d.ts +6 -6
- package/dist/index.js +4 -4
- package/dist/middleware.d.ts +1 -1
- package/dist/run-bundle.d.ts +6 -1
- package/dist/run-bundle.js +4 -1
- package/dist/run-limits.js +13 -0
- package/dist/testing/prefix-stability-conformance.d.ts +29 -0
- package/dist/testing/prefix-stability-conformance.js +91 -23
- package/dist/tools.js +10 -3
- package/docs/agent-events.md +12 -8
- package/docs/agent-session-runtime.md +9 -6
- package/docs/caveman.md +1 -1
- package/docs/compaction-llm.md +2 -0
- package/docs/compaction-observational-memory.md +21 -1
- package/docs/durable-runs.md +4 -3
- package/docs/embeddings.md +5 -1
- package/docs/execution-timeline.md +3 -2
- package/docs/extensions.md +20 -3
- package/docs/guardrails.md +16 -6
- package/docs/hooks.md +282 -0
- package/docs/index.md +18 -15
- package/docs/input-and-prompt-assembly.md +1 -1
- package/docs/instruction-injection.md +1 -0
- package/docs/live-testing.md +3 -1
- package/docs/memory-fabric.md +28 -0
- package/docs/middleware-hooks.md +54 -4
- package/docs/migration.md +13 -0
- package/docs/options-index.md +3 -1
- package/docs/policy-and-audit.md +14 -1
- package/docs/prefix-stability-conformance.md +57 -7
- package/docs/provider-packages.md +20 -20
- package/docs/public-contracts.md +1 -0
- package/docs/rag.md +93 -6
- package/docs/release-and-install.md +42 -39
- package/docs/runs-and-usage.md +17 -8
- package/docs/scoped-agent-memory.md +17 -9
- package/docs/scoped-memory.md +138 -0
- package/docs/tools.md +1 -1
- package/docs/wiki.md +4 -2
- package/package.json +4 -2
|
@@ -56,6 +56,104 @@ class TurnPolicyError extends Error {
|
|
|
56
56
|
this.name = "TurnPolicyError";
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
/** Stop-hook misuse: a throwing or malformed hook fails the run closed (plan 106 R1). */
|
|
60
|
+
class StopHookError extends Error {
|
|
61
|
+
code = "ERR_PRISM_STOP_HOOK";
|
|
62
|
+
constructor(message, options) {
|
|
63
|
+
super(message, options);
|
|
64
|
+
this.name = "StopHookError";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Validate the merged stop-hook list once per run, before any provider turn (plan 106 R1). */
|
|
68
|
+
function assertStopHooks(hooks) {
|
|
69
|
+
for (const hook of hooks) {
|
|
70
|
+
if (typeof hook !== "object" ||
|
|
71
|
+
hook === null ||
|
|
72
|
+
typeof hook.name !== "string" ||
|
|
73
|
+
hook.name.length === 0 ||
|
|
74
|
+
typeof hook.decide !== "function") {
|
|
75
|
+
throw new TypeError("stopHooks entries must be StopHook objects with a non-empty name and a decide function");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Run stop hooks in order at a natural loop end (plan 106 R1). The first `continue` wins; every
|
|
81
|
+
* `stop` (or no hook continuing) leaves the run finished. Hook context is metadata plus the live
|
|
82
|
+
* transcript — tool arguments, prompts, and results are never reshaped by core.
|
|
83
|
+
*/
|
|
84
|
+
async function evaluateStopHooks(ctx, stopHookActive) {
|
|
85
|
+
const context = {
|
|
86
|
+
sessionId: ctx.session.id,
|
|
87
|
+
runId: ctx.runId,
|
|
88
|
+
turn: ctx.limits.snapshot().turns,
|
|
89
|
+
history: ctx.loopCtx.history,
|
|
90
|
+
metadata: ctx.metadata,
|
|
91
|
+
signal: ctx.controller.signal,
|
|
92
|
+
stopHookActive,
|
|
93
|
+
};
|
|
94
|
+
for (const hook of ctx.stopHooks) {
|
|
95
|
+
let decision;
|
|
96
|
+
try {
|
|
97
|
+
decision = await hook.decide(context);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
throw new StopHookError(`Stop hook "${hook.name}" threw`, { cause: error });
|
|
101
|
+
}
|
|
102
|
+
if (decision === null || typeof decision !== "object") {
|
|
103
|
+
throw new StopHookError(`Stop hook "${hook.name}" must return a StopHookDecision`);
|
|
104
|
+
}
|
|
105
|
+
const action = decision.action;
|
|
106
|
+
if (action === "stop")
|
|
107
|
+
continue;
|
|
108
|
+
if (action !== "continue") {
|
|
109
|
+
throw new StopHookError(`Stop hook "${hook.name}" decision action must be "stop" or "continue"`);
|
|
110
|
+
}
|
|
111
|
+
const reason = decision.reason;
|
|
112
|
+
if (typeof reason !== "string" || reason.length === 0) {
|
|
113
|
+
throw new StopHookError(`Stop hook "${hook.name}" continue decision requires a non-empty reason string`);
|
|
114
|
+
}
|
|
115
|
+
const steer = decision.steer;
|
|
116
|
+
if (!isStopHookSteer(steer)) {
|
|
117
|
+
throw new StopHookError(`Stop hook "${hook.name}" steer must be a string or Message`);
|
|
118
|
+
}
|
|
119
|
+
return steer === undefined ? { reason } : { reason, steer };
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
function isStopHookSteer(value) {
|
|
124
|
+
if (value === undefined || typeof value === "string")
|
|
125
|
+
return true;
|
|
126
|
+
if (typeof value !== "object" || value === null)
|
|
127
|
+
return false;
|
|
128
|
+
const message = value;
|
|
129
|
+
return typeof message.role === "string" && Array.isArray(message.content);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Queue a continuation through the host steer path (plan 106 R1): same redaction, same 8-message /
|
|
133
|
+
* 64 KiB caps, and the same input-guardrail re-check when the loop drains it. A queue failure fails
|
|
134
|
+
* the run closed — the hook asked for something the run cannot deliver.
|
|
135
|
+
*/
|
|
136
|
+
function queueStopHookContinuation(ctx, decision) {
|
|
137
|
+
const messages = [{ role: "user", content: [{ type: "text", text: decision.reason }] }];
|
|
138
|
+
if (decision.steer !== undefined) {
|
|
139
|
+
messages.push(typeof decision.steer === "string" ? { role: "user", content: [{ type: "text", text: decision.steer }] } : decision.steer);
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
ctx.session.steer(messages);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
throw new StopHookError("Stop hook continuation could not be queued", { cause: error });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** The generate-validate-revise loop promises a validated artifact; a bare return is a failure. */
|
|
149
|
+
function assertArtifactOutcome(ctx) {
|
|
150
|
+
if (ctx.loop.name === "generate-validate-revise" && !ctx.artifactFinished) {
|
|
151
|
+
throw Object.assign(new Error(ctx.artifactFailedInfo?.message ?? "artifact loop ended without a validated artifact"), {
|
|
152
|
+
name: "ArtifactFailed",
|
|
153
|
+
code: ctx.artifactFailedInfo?.code ?? "artifact_failed",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
59
157
|
/** Validate `RunOptions.turnPolicy` once, before any provider turn (plan 084 Task 2). */
|
|
60
158
|
function assertTurnPolicy(policy, resolvedLimits) {
|
|
61
159
|
if (policy === undefined)
|
|
@@ -146,7 +244,7 @@ function assertPromptVersionRef(ref) {
|
|
|
146
244
|
return ref;
|
|
147
245
|
}
|
|
148
246
|
async function assembleRoundContext(params) {
|
|
149
|
-
const { session, input, options, runId, resumed, controller, model, startedAt, promptVersion, metadata, limits, runUsage } = params;
|
|
247
|
+
const { session, input, options, runId, resumed, controller, model, startedAt, promptVersion, metadata, limits, runUsage, stopHooks } = params;
|
|
150
248
|
session.resolveRunProvider(options);
|
|
151
249
|
throwIfAborted(controller.signal);
|
|
152
250
|
session.emit({ type: "agent_started", sessionId: session.id, runId });
|
|
@@ -158,6 +256,9 @@ async function assembleRoundContext(params) {
|
|
|
158
256
|
version: resumed.version,
|
|
159
257
|
...(resumed.restore ? { restore: resumed.restore } : {}),
|
|
160
258
|
});
|
|
259
|
+
// Plan 106 R2: first run start of this session opens it. Awaited after the two emits above so the
|
|
260
|
+
// run's synchronous announce burst stays intact; middleware error policy owns failures.
|
|
261
|
+
await session.openSession(runId);
|
|
161
262
|
const startRecord = {
|
|
162
263
|
id: runId,
|
|
163
264
|
sessionId: session.id,
|
|
@@ -304,6 +405,7 @@ async function assembleRoundContext(params) {
|
|
|
304
405
|
artifactFailedInfo: undefined,
|
|
305
406
|
toolCalls: 0,
|
|
306
407
|
toolResults: [],
|
|
408
|
+
stopHooks,
|
|
307
409
|
runUsage,
|
|
308
410
|
loopCtx: undefined,
|
|
309
411
|
};
|
|
@@ -373,6 +475,7 @@ async function assembleRoundContext(params) {
|
|
|
373
475
|
toolsSearch: session.agent.config.toolsSearch,
|
|
374
476
|
activatedTools: session.activatedTools,
|
|
375
477
|
toolResultFold: resolveToolResultFold(options.toolResultFold, session.agent.config.toolResultFold),
|
|
478
|
+
contextBudget: session.agent.config.contextBudget,
|
|
376
479
|
attentionCompiler,
|
|
377
480
|
// Session-owned: a stub made earlier stays applied even on a later under-ratio turn, so
|
|
378
481
|
// the prompt-cache prefix is not rewritten (C10). Undefined when the compiler is off.
|
|
@@ -469,6 +572,50 @@ async function assembleRoundContext(params) {
|
|
|
469
572
|
ctx.loopCtx = loopCtx;
|
|
470
573
|
return ctx;
|
|
471
574
|
}
|
|
575
|
+
/**
|
|
576
|
+
* Run the loop to settlement, then apply stop hooks at the natural loop end (plan 106 R1). Each
|
|
577
|
+
* `continue` queues its reason through the steer path and re-enters the loop with a continuation
|
|
578
|
+
* context whose `input`/`inputMessages` are empty — the continuation message is already in
|
|
579
|
+
* `history`, and replaying run-start input would duplicate it. A loop ceiling, a host turn-policy
|
|
580
|
+
* stop, or an artifact failure is not a natural end: hooks never run there, and a continuation leg
|
|
581
|
+
* that hits a ceiling ends the run instead of asking again. `limits.maxStopContinuations`
|
|
582
|
+
* (default 3; `0` observes only; `null` uncapped) bounds continuations as a clean `hook_limit` stop.
|
|
583
|
+
*/
|
|
584
|
+
async function runLoopWithStopHooks(ctx) {
|
|
585
|
+
let usage = await runLoopUntilSettled(ctx);
|
|
586
|
+
assertArtifactOutcome(ctx);
|
|
587
|
+
// Zero overhead when nothing is configured: no wrapper state, no reads.
|
|
588
|
+
if (ctx.stopHooks.length === 0)
|
|
589
|
+
return usage;
|
|
590
|
+
const cap = ctx.limits.limits.maxStopContinuations;
|
|
591
|
+
let continuations = 0;
|
|
592
|
+
let stopHookActive = false;
|
|
593
|
+
for (;;) {
|
|
594
|
+
if (ctx.runStop !== undefined || ctx.loopCtx.finishReason !== undefined)
|
|
595
|
+
return usage;
|
|
596
|
+
const decision = await evaluateStopHooks(ctx, stopHookActive);
|
|
597
|
+
if (!decision)
|
|
598
|
+
return usage;
|
|
599
|
+
if (cap !== null && continuations >= cap) {
|
|
600
|
+
ctx.loopCtx.finishReason = "hook_limit";
|
|
601
|
+
return usage;
|
|
602
|
+
}
|
|
603
|
+
continuations += 1;
|
|
604
|
+
stopHookActive = true;
|
|
605
|
+
queueStopHookContinuation(ctx, decision);
|
|
606
|
+
const continuationCtx = { ...ctx.loopCtx, input: [], inputMessages: [], continuation: true };
|
|
607
|
+
try {
|
|
608
|
+
usage = await runLoopUntilSettled({ ...ctx, loopCtx: continuationCtx });
|
|
609
|
+
}
|
|
610
|
+
finally {
|
|
611
|
+
// The loops set `finishReason` on the context they receive; carry it back so the ceiling
|
|
612
|
+
// survives onto the result, the finish record, and `persistSucceeded`.
|
|
613
|
+
if (continuationCtx.finishReason !== undefined)
|
|
614
|
+
ctx.loopCtx.finishReason = continuationCtx.finishReason;
|
|
615
|
+
}
|
|
616
|
+
assertArtifactOutcome(ctx);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
472
619
|
export async function executeRun(session, input, options, runId, resumed) {
|
|
473
620
|
const legacyMaxToolRounds = options.maxToolRounds;
|
|
474
621
|
if (legacyMaxToolRounds !== undefined) {
|
|
@@ -486,6 +633,8 @@ export async function executeRun(session, input, options, runId, resumed) {
|
|
|
486
633
|
const requestedLimits = options.limits;
|
|
487
634
|
const resolvedLimits = resolveRunLimits(session.agent.config.limits, requestedLimits);
|
|
488
635
|
assertTurnPolicy(options.turnPolicy, resolvedLimits);
|
|
636
|
+
const stopHooks = [...(session.agent.config.stopHooks ?? []), ...(options.stopHooks ?? [])];
|
|
637
|
+
assertStopHooks(stopHooks);
|
|
489
638
|
const durableOptions = options.runState ?? session.agent.config.runState;
|
|
490
639
|
if (session.agent.config.runState && options.runState && session.agent.config.runState !== options.runState) {
|
|
491
640
|
throw new AgentRunStateError("RunOptions cannot replace agent durable run-state configuration");
|
|
@@ -519,8 +668,11 @@ export async function executeRun(session, input, options, runId, resumed) {
|
|
|
519
668
|
if (session.activeIdentity && !session.activeOwnership)
|
|
520
669
|
session.activeOwnership = ownershipFromIdentity(session.activeIdentity);
|
|
521
670
|
session.activeIdempotencyKey = options.idempotencyKey ?? session.agent.config.idempotencyKey;
|
|
522
|
-
session.activeGuardrails = mergeGuardrails(mergeGuardrails(session.agent.config.guardrails, session.packGuardrails), options.guardrails);
|
|
523
671
|
session.activeDurable = resumed ?? (durableOptions ? { options: durableOptions, version: 0 } : undefined);
|
|
672
|
+
// Plan 104 T3: an `ask` rule is gated at charge time when the run can suspend; a run that cannot
|
|
673
|
+
// suspend enforces the same rule as a plain block, so it joins the ordinary stage guardrails.
|
|
674
|
+
const packGuardrails = session.activeDurable ? session.packGuardrails : mergeGuardrails(session.packGuardrails, session.packAskBlocks);
|
|
675
|
+
session.activeGuardrails = mergeGuardrails(mergeGuardrails(session.agent.config.guardrails, packGuardrails), options.guardrails);
|
|
524
676
|
// Plan 086 T3: reset here, so a suspension before the compiler is resolved (input guardrail)
|
|
525
677
|
// cannot inherit the previous run's durable-folding flag. `assembleRoundContext` sets it true.
|
|
526
678
|
session.attentionDurable = false;
|
|
@@ -570,6 +722,7 @@ export async function executeRun(session, input, options, runId, resumed) {
|
|
|
570
722
|
metadata,
|
|
571
723
|
limits,
|
|
572
724
|
runUsage,
|
|
725
|
+
stopHooks,
|
|
573
726
|
});
|
|
574
727
|
await replayDurableNestedAndPending(ctx);
|
|
575
728
|
const resumedLoopState = resumed?.state?.loopState;
|
|
@@ -579,7 +732,7 @@ export async function executeRun(session, input, options, runId, resumed) {
|
|
|
579
732
|
}
|
|
580
733
|
ctx.loop.restore?.(resumedLoopState.snapshot);
|
|
581
734
|
}
|
|
582
|
-
const loopUsage = await
|
|
735
|
+
const loopUsage = await runLoopWithStopHooks(ctx).catch((error) => {
|
|
583
736
|
// Host turn-policy stop (plan 084 Task 2): the loop was unwound on purpose at a turn
|
|
584
737
|
// boundary. Not an error — the run settles cleanly and stays resumable.
|
|
585
738
|
if (error instanceof AgentRunStopped)
|
|
@@ -588,12 +741,6 @@ export async function executeRun(session, input, options, runId, resumed) {
|
|
|
588
741
|
});
|
|
589
742
|
stopReason = ctx.runStop?.reason ?? ctx.loopCtx.finishReason;
|
|
590
743
|
stopDetail = ctx.runStop?.detail;
|
|
591
|
-
if (!ctx.runStop && ctx.loop.name === "generate-validate-revise" && !ctx.artifactFinished) {
|
|
592
|
-
throw Object.assign(new Error(ctx.artifactFailedInfo?.message ?? "artifact loop ended without a validated artifact"), {
|
|
593
|
-
name: "ArtifactFailed",
|
|
594
|
-
code: ctx.artifactFailedInfo?.code ?? "artifact_failed",
|
|
595
|
-
});
|
|
596
|
-
}
|
|
597
744
|
usage = runUsage.value() ?? loopUsage;
|
|
598
745
|
return await persistSucceeded(ctx, loopUsage);
|
|
599
746
|
}
|
|
@@ -16,10 +16,14 @@ export async function persistDurable(session, state) {
|
|
|
16
16
|
// did not keeps exactly today's bytes, where the frontier rides `persistSessionState`.
|
|
17
17
|
const attentionSticky = persistSessionState || session.attentionDurable ? session.serializedAttentionSticky() : undefined;
|
|
18
18
|
const attentionFold = session.attentionDurable ? session.serializedAttentionFold() : undefined;
|
|
19
|
+
// Plan 104 T2: pack refs and pack-owned state; the key only exists under the same opt-in, so a
|
|
20
|
+
// default checkpoint keeps exactly today's bytes.
|
|
21
|
+
const guardrailPacks = persistSessionState ? session.serializedGuardrailPackState() : undefined;
|
|
19
22
|
const sessionState = {
|
|
20
23
|
...(persistSessionState
|
|
21
24
|
? {
|
|
22
25
|
loadedSkillNames: session.loadedSkills.list(),
|
|
26
|
+
...(guardrailPacks ? { guardrailPacks } : {}),
|
|
23
27
|
...(session.activatedTools.list().length ? { activatedToolNames: session.activatedTools.list() } : {}),
|
|
24
28
|
...(durable.options.includeSkillBodies
|
|
25
29
|
? {
|
|
@@ -160,19 +164,21 @@ export async function persistSucceeded(ctx, loopUsage) {
|
|
|
160
164
|
await session.activeLedger.appendUsage(redactRunLedgerRecord(usageRecord, session.activeRedactor));
|
|
161
165
|
}
|
|
162
166
|
await session.drainLedger();
|
|
167
|
+
// Plan 084 Task 2 / plan 106 R1: a clean run-end stop keeps the frontier and marks the state
|
|
168
|
+
// continuable — a host turn-policy stop (`host_policy`) or a stop-hook continuation cap
|
|
169
|
+
// (`hook_limit`). Every other succeeded state drops its loop state and is final.
|
|
170
|
+
const continuableStop = stop ? "host_policy" : ctx.loopCtx.finishReason === "hook_limit" ? "hook_limit" : undefined;
|
|
163
171
|
const runState = session.activeDurable?.state
|
|
164
172
|
? await persistDurable(session, {
|
|
165
173
|
...session.activeDurable.state,
|
|
166
174
|
status: "succeeded",
|
|
167
|
-
|
|
168
|
-
// intact — the loop state is kept and the state is marked continuable.
|
|
169
|
-
...(stop ? { stopReason: "host_policy", leafId: session.currentLeafId } : {}),
|
|
175
|
+
...(continuableStop ? { stopReason: continuableStop, leafId: session.currentLeafId } : {}),
|
|
170
176
|
pending: undefined,
|
|
171
177
|
pendingCalls: undefined,
|
|
172
178
|
nestedRuns: undefined,
|
|
173
179
|
stickyDecisions: undefined,
|
|
174
180
|
interruption: undefined,
|
|
175
|
-
...(
|
|
181
|
+
...(continuableStop ? {} : { loopState: undefined }),
|
|
176
182
|
})
|
|
177
183
|
: undefined;
|
|
178
184
|
session.emit({
|
|
@@ -246,7 +252,7 @@ export async function cleanupRun(input) {
|
|
|
246
252
|
session.activeRedactor = undefined;
|
|
247
253
|
session.activeProvider = undefined;
|
|
248
254
|
cleanupSignal();
|
|
249
|
-
session.
|
|
255
|
+
session.closeRunSubscribers();
|
|
250
256
|
}
|
|
251
257
|
}
|
|
252
258
|
//# sourceMappingURL=persist.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Provider-round phase of runInternal (plan 059). */
|
|
2
2
|
import { resolveInputCap } from "../../attention-compiler.js";
|
|
3
3
|
import { cacheUsageReport } from "../../cache-helpers.js";
|
|
4
|
-
import { estimateMessageTokens } from "../../context-budget.js";
|
|
4
|
+
import { estimateMessageTokens, estimateRequestExtrasTokens, getContextBudgetReport, resolveHostTokenEstimator, } from "../../context-budget.js";
|
|
5
5
|
import { assertGuardrailsAllowed, GuardrailError, runGuardrails } from "../../guardrails.js";
|
|
6
6
|
import { validateDeterministicTurnAnswer } from "../../middleware.js";
|
|
7
7
|
import { createProviderTurnMetadata, readProviderHttpStatus } from "../../observability.js";
|
|
@@ -48,7 +48,9 @@ function turnBudgets(session, model, usage) {
|
|
|
48
48
|
const inputCap = resolveTurnInputCap(session, model);
|
|
49
49
|
const runInputBudget = tracker.limits.maxInputTokens;
|
|
50
50
|
return {
|
|
51
|
-
...(usage?.inputTokens === undefined
|
|
51
|
+
...(usage?.inputTokens === undefined
|
|
52
|
+
? {}
|
|
53
|
+
: { inputTokens: usage.inputTokens, inputTokensSource: usage.estimated === true ? "estimated" : "reported" }),
|
|
52
54
|
...(inputCap === undefined ? {} : { inputCap }),
|
|
53
55
|
...(runInputBudget === null ? {} : { runInputBudget }),
|
|
54
56
|
runInputUsed: snapshot.inputTokens,
|
|
@@ -125,21 +127,52 @@ export async function recordProviderUsage(ctx, turnUsage, turn, attempt, request
|
|
|
125
127
|
return effective;
|
|
126
128
|
}
|
|
127
129
|
/**
|
|
128
|
-
* Plan 091 T2 missing-usage fallback
|
|
129
|
-
* agent did not turn estimation off, label
|
|
130
|
-
*
|
|
131
|
-
*
|
|
130
|
+
* Plan 091 T2 missing-usage fallback, plan 103 T6 exact-measurement reuse: when the provider
|
|
131
|
+
* reported nothing and the agent did not turn estimation off, label one estimate of the turn's
|
|
132
|
+
* own request, preferring the most exact measurement that already exists —
|
|
133
|
+
* 1. the budget pass's own `ContextBudgetReport.keptTokens` (whole request, post-eviction,
|
|
134
|
+
* the same figure that decided evictions; excludes content added after the budget pass),
|
|
135
|
+
* 2. the host's `contextBudget.tokenEstimator`, projecting messages plus tool/context portions
|
|
136
|
+
* through the assembler's own `measureAll` text shapes,
|
|
137
|
+
* 3. the plan-091 family heuristic for messages plus those same assembler shapes for extras
|
|
138
|
+
* (no `JSON.stringify` of the schemas, so no drift from what the assembler measured).
|
|
139
|
+
* A host tokenizer's count is still an estimate (`confidence: "high"`, never `"reported"`);
|
|
140
|
+
* a report measured by the built-in ÷4 basis is honestly `"low"`. Returns `undefined` when
|
|
141
|
+
* estimation is not the fallback (`"off"` / `"strict"`) or the request is unavailable —
|
|
142
|
+
* absent stays absent.
|
|
132
143
|
*/
|
|
133
144
|
function estimateTurnUsage(session, model, request) {
|
|
134
|
-
|
|
145
|
+
// Omitted is the documented default (`"fallback"`), not a reason to skip estimation.
|
|
146
|
+
const mode = session.agent.config.usageEstimation ?? "fallback";
|
|
147
|
+
if (!request || mode !== "fallback")
|
|
135
148
|
return undefined;
|
|
149
|
+
const hostEstimator = resolveHostTokenEstimator(session.agent.config.contextBudget);
|
|
150
|
+
const report = getContextBudgetReport(request);
|
|
151
|
+
if (report) {
|
|
152
|
+
return { inputTokens: report.keptTokens, estimated: true, confidence: hostEstimator === undefined ? "low" : "high" };
|
|
153
|
+
}
|
|
154
|
+
if (hostEstimator) {
|
|
155
|
+
let tokens = estimateRequestExtrasTokens(request.tools, request.context, hostEstimator);
|
|
156
|
+
for (const message of request.messages)
|
|
157
|
+
tokens += estimateMessageTokens(message, hostEstimator);
|
|
158
|
+
return { inputTokens: tokens, estimated: true, confidence: "high" };
|
|
159
|
+
}
|
|
136
160
|
const estimate = estimateMessageTokens(request.messages, model.model);
|
|
137
|
-
const extras = request.tools
|
|
138
|
-
return {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
161
|
+
const extras = estimateRequestExtrasTokens(request.tools, request.context, (text) => estimateTextTokensForFamily(text, model.model));
|
|
162
|
+
return { inputTokens: estimate.tokens + extras, estimated: true, confidence: estimate.confidence };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Plan 103 T5: the refusal `usageEstimation: "strict"` gives a completed turn that reported no
|
|
166
|
+
* usage. It rides the existing observable-failure path (one attempt, terminal `error` event) and
|
|
167
|
+
* stamps no `failureClass` — a harness refusal is not a provider failure, so `name`/`code` are
|
|
168
|
+
* what a host matches on. The info carries the turn number and mode only, never request content.
|
|
169
|
+
*/
|
|
170
|
+
function usageMissingFailure(turn) {
|
|
171
|
+
return new ProviderTurnFailure({
|
|
172
|
+
name: "UsageMissingError",
|
|
173
|
+
code: "usage_missing",
|
|
174
|
+
message: `provider reported no usage on turn ${turn} and usageEstimation is "strict"`,
|
|
175
|
+
}, true);
|
|
143
176
|
}
|
|
144
177
|
/** Latest user-role text in the assembled request; steered messages included. */
|
|
145
178
|
function lastUserText(messages) {
|
|
@@ -333,6 +366,14 @@ export async function generateProviderTurn(session, request, runId, signal, secr
|
|
|
333
366
|
calls.push(call);
|
|
334
367
|
emitOutput({ type: "message_delta", sessionId: session.id, runId, content: call });
|
|
335
368
|
}
|
|
369
|
+
// Plan 103 T5: strict refuses a completed turn that reported no usage *before* the usage seam
|
|
370
|
+
// runs, so no estimate is projected, the cost catalog is not consulted, and the fail-closed
|
|
371
|
+
// `recordUsage(undefined)` maxCost breach cannot preempt the refusal. Marking the seam
|
|
372
|
+
// consulted keeps the catch below from re-entering it with the same missing usage.
|
|
373
|
+
if (usage === undefined && session.agent.config.usageEstimation === "strict") {
|
|
374
|
+
usageRecorded = true;
|
|
375
|
+
throw usageMissingFailure(turn);
|
|
376
|
+
}
|
|
336
377
|
await recordTurnUsage();
|
|
337
378
|
if (session.activeGuardrails?.output?.length) {
|
|
338
379
|
assertGuardrailsAllowed(await runGuardrails({
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/** Tool-round phase of runInternal (plan 059). */
|
|
2
|
-
import type { AgentRunRef, LoopContext, NestedRunRef, PendingDecision, ResumeNestedRun, StickyDecision, ToolCallContent, ToolRegistry, ToolResult, Usage } from "../../contracts.js";
|
|
2
|
+
import type { AgentRunRef, GuardrailRecord, LoopContext, NestedRunRef, PendingDecision, ResumeNestedRun, StickyDecision, ToolCallContent, ToolRegistry, ToolResult, Usage } from "../../contracts.js";
|
|
3
3
|
import { AgentDelegationSuspendedError } from "../../contracts.js";
|
|
4
4
|
import type { RoundContext, SessionHost } from "./types.js";
|
|
5
5
|
export declare function matchNestedSticky(session: SessionHost, decision: PendingDecision): StickyDecision | undefined;
|
|
6
6
|
export declare function matchStickyDecision(session: SessionHost, call: ToolCallContent, registry: ToolRegistry): StickyDecision | undefined;
|
|
7
|
-
export declare function buildPendingDecision(session: SessionHost, call: ToolCallContent, approvalId: string, registry: ToolRegistry, runId: string, metadata: Readonly<Record<string, unknown>>, signal: AbortSignal): PendingDecision;
|
|
7
|
+
export declare function buildPendingDecision(session: SessionHost, call: ToolCallContent, approvalId: string, registry: ToolRegistry, runId: string, metadata: Readonly<Record<string, unknown>>, signal: AbortSignal, ask?: GuardrailRecord): PendingDecision;
|
|
8
8
|
export declare function applyNestedRun(session: SessionHost, input: {
|
|
9
9
|
ref: AgentRunRef;
|
|
10
10
|
toolCall: ToolCallContent;
|
|
@@ -3,6 +3,7 @@ import { AgentRunSuspended, decisionIdentityRef, decisionScopesEqual, nestedAppr
|
|
|
3
3
|
import { toolElicitationRequest } from "../../agent-tool-dispatch.js";
|
|
4
4
|
import { AgentDecisionError, AgentDelegationSuspendedError, AgentRunStateError, DEFAULT_MAX_PENDING_DECISIONS, HARD_MAX_PENDING_DECISIONS, MAX_ATTRIBUTION_DEPTH, } from "../../contracts.js";
|
|
5
5
|
import { toToolResultMessage } from "../../input.js";
|
|
6
|
+
import { runGuardrails } from "../../guardrails.js";
|
|
6
7
|
import { canonicalToolEffectJson, toolEffectArgumentsHash } from "../../tool-effects.js";
|
|
7
8
|
import { dispatchToolCall, resolveToolEffectDeclaration } from "../../tools.js";
|
|
8
9
|
import { randomId } from "../helpers.js";
|
|
@@ -55,7 +56,7 @@ export function matchStickyDecision(session, call, registry) {
|
|
|
55
56
|
return true;
|
|
56
57
|
});
|
|
57
58
|
}
|
|
58
|
-
export function buildPendingDecision(session, call, approvalId, registry, runId, metadata, signal) {
|
|
59
|
+
export function buildPendingDecision(session, call, approvalId, registry, runId, metadata, signal, ask) {
|
|
59
60
|
const tool = registry.get(call.name);
|
|
60
61
|
const declaration = tool?.effect
|
|
61
62
|
? resolveToolEffectDeclaration(tool, call.arguments, {
|
|
@@ -74,6 +75,11 @@ export function buildPendingDecision(session, call, approvalId, registry, runId,
|
|
|
74
75
|
signal,
|
|
75
76
|
metadata,
|
|
76
77
|
});
|
|
78
|
+
// Plan 104 T3: the pack `ask` rule that gated this call is named in the bounded reason and carried
|
|
79
|
+
// machine-readably, so a host never parses the name to know which rule raised the approval.
|
|
80
|
+
const pack = ask?.metadata?.pack;
|
|
81
|
+
const rule = ask?.metadata?.rule;
|
|
82
|
+
const guardrailRule = typeof pack === "string" && typeof rule === "string" ? { pack, rule } : undefined;
|
|
77
83
|
return {
|
|
78
84
|
approvalId,
|
|
79
85
|
kind: elicitation ? "elicitation" : "tool_approval",
|
|
@@ -84,10 +90,22 @@ export function buildPendingDecision(session, call, approvalId, registry, runId,
|
|
|
84
90
|
...(declaration && declaration.kind !== "none" ? { effectKind: declaration.kind } : {}),
|
|
85
91
|
...(identityRef ? { identity: identityRef } : {}),
|
|
86
92
|
},
|
|
87
|
-
reason: elicitation?.reason ?? "Tool side effect requires approval",
|
|
93
|
+
reason: elicitation?.reason ?? (ask ? askDecisionReason(ask) : "Tool side effect requires approval"),
|
|
88
94
|
...(elicitation ? { elicitationSchema: elicitation.schema } : {}),
|
|
95
|
+
...(ask && guardrailRule ? { guardrail: ask.guardrail, guardrailRule } : {}),
|
|
89
96
|
};
|
|
90
97
|
}
|
|
98
|
+
const MAX_ASK_DECISION_REASON_BYTES = 200;
|
|
99
|
+
/** `pack:<pack>/<rule>` plus the pack's own reason, bounded like every other decision field. */
|
|
100
|
+
function askDecisionReason(ask) {
|
|
101
|
+
const pack = ask.metadata?.pack;
|
|
102
|
+
const rule = ask.metadata?.rule;
|
|
103
|
+
const defaultReason = typeof pack === "string" && typeof rule === "string" ? `guardrail pack rule ${pack}/${rule}` : undefined;
|
|
104
|
+
const line = `Approval required by guardrail rule ${ask.guardrail}`;
|
|
105
|
+
const text = ask.reason && ask.reason !== defaultReason ? `${line}: ${ask.reason}` : line;
|
|
106
|
+
const bytes = new TextEncoder().encode(text);
|
|
107
|
+
return bytes.length <= MAX_ASK_DECISION_REASON_BYTES ? text : new TextDecoder().decode(bytes.subarray(0, MAX_ASK_DECISION_REASON_BYTES));
|
|
108
|
+
}
|
|
91
109
|
export async function applyNestedRun(session, input) {
|
|
92
110
|
let current = input.pending;
|
|
93
111
|
for (let depth = 0;; depth += 1) {
|
|
@@ -142,6 +160,7 @@ export async function suspendGatedRound(ctx) {
|
|
|
142
160
|
reason: single ? single.reason : `${decisions.length} tool side effects require approval`,
|
|
143
161
|
...(single?.toolCallId ? { toolCallId: single.toolCallId } : {}),
|
|
144
162
|
...(single?.scope.toolName ? { toolName: single.scope.toolName } : {}),
|
|
163
|
+
...(single?.guardrail ? { guardrail: single.guardrail } : {}),
|
|
145
164
|
pendingDecisions: decisions,
|
|
146
165
|
};
|
|
147
166
|
throw new AgentRunSuspended(await suspendDurable(ctx.session, {
|
|
@@ -217,20 +236,27 @@ export async function handleNestedSignal(ctx, error) {
|
|
|
217
236
|
await suspendNested(ctx, { entry: applied.entry, toolCall: error.toolCall, pending: applied.pending });
|
|
218
237
|
}
|
|
219
238
|
export function bindChargeToolRound(ctx) {
|
|
220
|
-
return (calls) => {
|
|
239
|
+
return async (calls) => {
|
|
221
240
|
if (calls.length > 0)
|
|
222
241
|
ctx.limits.charge("maxToolRounds");
|
|
223
242
|
const durable = ctx.session.activeDurable;
|
|
224
|
-
|
|
243
|
+
// A run that cannot suspend never gates here: `activeGuardrails` already carries the pack `ask`
|
|
244
|
+
// rules as plain blocks (assemble.ts), so the ordinary stage path refuses the call.
|
|
245
|
+
if (!durable || calls.length === 0)
|
|
246
|
+
return;
|
|
247
|
+
if (!ctx.session.packAskGate && !durable.options.interruptBeforeTool)
|
|
225
248
|
return;
|
|
226
249
|
for (const call of calls) {
|
|
227
250
|
if (matchStickyDecision(ctx.session, call, ctx.registry))
|
|
228
251
|
continue;
|
|
252
|
+
const ask = await matchAskGate(ctx, call);
|
|
253
|
+
if (!ask && !durable.options.interruptBeforeTool)
|
|
254
|
+
continue;
|
|
229
255
|
const approvalId = randomId("approval");
|
|
230
256
|
ctx.session.activeGatedRound ??= new Map();
|
|
231
257
|
ctx.session.activeGatedRound.set(call.id, {
|
|
232
258
|
entry: { call, status: "ready", approvalId },
|
|
233
|
-
decision: buildPendingDecision(ctx.session, call, approvalId, ctx.registry, ctx.runId, ctx.metadata, ctx.controller.signal),
|
|
259
|
+
decision: buildPendingDecision(ctx.session, call, approvalId, ctx.registry, ctx.runId, ctx.metadata, ctx.controller.signal, ask),
|
|
234
260
|
});
|
|
235
261
|
}
|
|
236
262
|
if (ctx.session.activeGatedRound && ctx.session.activeGatedRound.size > DEFAULT_MAX_PENDING_DECISIONS) {
|
|
@@ -238,6 +264,33 @@ export function bindChargeToolRound(ctx) {
|
|
|
238
264
|
}
|
|
239
265
|
};
|
|
240
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Plan 104 T3: evaluate the pack `ask` rules for one call at charge time. The rules run through the
|
|
269
|
+
* same compiler and stage runner as every other pack rule, so matching, bounds, and redaction are
|
|
270
|
+
* shared; a match emits its `guardrail_decision` (`interrupt`: awaiting a decision) and gates the
|
|
271
|
+
* call before it can dispatch.
|
|
272
|
+
*/
|
|
273
|
+
async function matchAskGate(ctx, call) {
|
|
274
|
+
const gate = ctx.session.packAskGate;
|
|
275
|
+
if (!gate)
|
|
276
|
+
return undefined;
|
|
277
|
+
const result = await runGuardrails({
|
|
278
|
+
stage: "tool_input",
|
|
279
|
+
guardrails: gate,
|
|
280
|
+
value: call,
|
|
281
|
+
context: {
|
|
282
|
+
sessionId: ctx.session.id,
|
|
283
|
+
runId: ctx.runId,
|
|
284
|
+
toolCallId: call.id,
|
|
285
|
+
toolName: call.name,
|
|
286
|
+
metadata: ctx.metadata,
|
|
287
|
+
signal: ctx.controller.signal,
|
|
288
|
+
},
|
|
289
|
+
redactor: ctx.session.activeRedactor,
|
|
290
|
+
emit: (event) => ctx.session.emit(event),
|
|
291
|
+
});
|
|
292
|
+
return result.terminal;
|
|
293
|
+
}
|
|
241
294
|
/** Last-N dispatched tool calls kept for `budget_exhausted` attribution (plan 087 T2); the hash
|
|
242
295
|
* is the same canonical arguments hash the effect store uses, so raw args never enter events. */
|
|
243
296
|
const RECENT_TOOL_CALL_LIMIT = 10;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/** Shared host/round types for runInternal phase split (plan 059). Internal only. */
|
|
2
2
|
import type { ActiveDurableRun } from "../../agent-approval.js";
|
|
3
|
-
import type { PendingToolCall } from "../../agent-run-state.js";
|
|
3
|
+
import type { PendingToolCall, PersistedGuardrailPacks } from "../../agent-run-state.js";
|
|
4
4
|
import type { AttentionFoldLedger, AttentionStickyFrontier, PersistedAttentionFoldLedger, PersistedAttentionStickyFrontier } from "../../attention-compiler.js";
|
|
5
|
-
import type { Agent, AgentEvent, AgentFinishReason, AgentLoopStrategy, AgentRunResult, AIProvider, ErrorInfo, Guardrails, LoopContext, Message, ModelConfig, OwnershipScope, PendingDecision, PromptVersionRef, ProviderRequest, RunLedger, RunOptions, SessionEntry, SessionStore, Skill, ToolCallSummary, ToolDefinition, ToolEffectStore, ToolRegistry, ToolResult, Usage } from "../../contracts.js";
|
|
5
|
+
import type { Agent, AgentEvent, AgentFinishReason, AgentLoopStrategy, AgentRunResult, AIProvider, ErrorInfo, Guardrails, LoopContext, Message, ModelConfig, OwnershipScope, PendingDecision, PromptVersionRef, ProviderRequest, RunLedger, RunOptions, SessionEntry, SessionStore, Skill, StopHook, ToolCallSummary, ToolDefinition, ToolEffectStore, ToolRegistry, ToolResult, Usage } from "../../contracts.js";
|
|
6
6
|
import type { AgentIdentity } from "../../identity.js";
|
|
7
7
|
import type { AgentInput } from "../../input.js";
|
|
8
8
|
import type { SecretRedactor } from "../../redaction.js";
|
|
@@ -35,6 +35,12 @@ export type SessionHost = {
|
|
|
35
35
|
activeGuardrails?: Guardrails;
|
|
36
36
|
/** Plan 092 Task 2: packs compiled once at session construction; read-only for phases. */
|
|
37
37
|
readonly packGuardrails?: Guardrails;
|
|
38
|
+
/** Plan 104 T3: `ask` rules as the durable charge-time gate (`interrupt` records) and as plain
|
|
39
|
+
* blocks for a run that cannot suspend. */
|
|
40
|
+
readonly packAskGate?: Guardrails;
|
|
41
|
+
readonly packAskBlocks?: Guardrails;
|
|
42
|
+
/** Plan 104 T2: pack refs + live pack-owned state for a durable checkpoint. */
|
|
43
|
+
serializedGuardrailPackState(): PersistedGuardrailPacks | undefined;
|
|
38
44
|
activeMetadata?: Readonly<Record<string, unknown>>;
|
|
39
45
|
activePromptVersion?: PromptVersionRef;
|
|
40
46
|
activeLimits?: RunLimitTracker;
|
|
@@ -83,6 +89,8 @@ export type SessionHost = {
|
|
|
83
89
|
emit(event: AgentEvent): void;
|
|
84
90
|
rebuildHistory(): Promise<void>;
|
|
85
91
|
resolveRunSkills(options: RunOptions, tools: readonly ToolDefinition[]): readonly Skill[];
|
|
92
|
+
/** Redacted, cap-checked steer queue push (plan 106 R1 uses it for stop-hook continuations). */
|
|
93
|
+
steer(input: AgentInput): void;
|
|
86
94
|
appendEntry(entry: SessionEntry): Promise<void>;
|
|
87
95
|
redact<T>(value: T): T;
|
|
88
96
|
appendMessage(message: Message, runId: string): Promise<void>;
|
|
@@ -104,7 +112,15 @@ export type SessionHost = {
|
|
|
104
112
|
readonly runState?: import("../../contracts.js").AgentRunState;
|
|
105
113
|
readonly interruption?: import("../../contracts.js").AgentRunInterruption;
|
|
106
114
|
}): AgentRunResult;
|
|
115
|
+
/**
|
|
116
|
+
* Plan 106 R2: dispatch `session_start` middleware once per session (first run start), awaited by
|
|
117
|
+
* the run assembler after the `agent_started`/`agent_resumed` emits. No-op on every later call.
|
|
118
|
+
*/
|
|
119
|
+
openSession(runId: string): Promise<void>;
|
|
120
|
+
/** Session teardown: close every subscriber, run-scoped and `acrossRuns` alike. */
|
|
107
121
|
closeSubscribers(): void;
|
|
122
|
+
/** Run end (finish, suspend, or deny): close only the subscribers that do not opt into `acrossRuns`. */
|
|
123
|
+
closeRunSubscribers(): void;
|
|
108
124
|
snapshot(): Promise<SessionContextSnapshot>;
|
|
109
125
|
};
|
|
110
126
|
export declare function asSessionHost(session: unknown): SessionHost;
|
|
@@ -151,6 +167,8 @@ export type RoundContext = {
|
|
|
151
167
|
toolResults: ToolResult[];
|
|
152
168
|
/** Set when a `RunOptions.turnPolicy` stop ended the loop (plan 084 Task 2). */
|
|
153
169
|
runStop?: RunStopInfo;
|
|
170
|
+
/** Merged agent + run stop hooks, in invocation order (plan 106 R1). Empty = wrapper skipped. */
|
|
171
|
+
stopHooks: readonly StopHook[];
|
|
154
172
|
runUsage: {
|
|
155
173
|
add(usage: Usage): void;
|
|
156
174
|
value(): Usage | undefined;
|