@arnilo/prism 0.7.0 → 0.9.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 +73 -0
- package/README.md +12 -11
- package/dist/agent-approval.d.ts +15 -2
- package/dist/agent-approval.js +5 -1
- package/dist/agent-event-source.d.ts +9 -1
- package/dist/agent-event-source.js +10 -3
- package/dist/agent-loops.js +7 -4
- package/dist/agent-run-lifecycle.d.ts +15 -1
- package/dist/agent-run-lifecycle.js +91 -10
- package/dist/agent-run-state.d.ts +34 -2
- package/dist/agent-run-state.js +68 -6
- package/dist/agent-session/helpers.js +20 -1
- package/dist/agent-session/session/assemble.js +250 -27
- package/dist/agent-session/session/persist.d.ts +27 -0
- package/dist/agent-session/session/persist.js +94 -12
- package/dist/agent-session/session/provider-round.d.ts +14 -4
- package/dist/agent-session/session/provider-round.js +197 -25
- package/dist/agent-session/session/tool-round.js +24 -2
- package/dist/agent-session/session/types.d.ts +36 -2
- package/dist/agent-session/session.d.ts +40 -4
- package/dist/agent-session/session.js +78 -5
- package/dist/attention-compiler.d.ts +51 -2
- package/dist/attention-compiler.js +282 -21
- package/dist/cache-helpers.d.ts +4 -2
- package/dist/cache-helpers.js +8 -6
- package/dist/checkpoint-restore.d.ts +45 -0
- package/dist/checkpoint-restore.js +54 -0
- package/dist/checkpoints.js +7 -11
- package/dist/context-budget.d.ts +2 -1
- package/dist/context-budget.js +24 -2
- package/dist/contracts-core/agent.d.ts +30 -0
- package/dist/contracts-core/attention.d.ts +95 -0
- package/dist/contracts-core/content.d.ts +15 -0
- package/dist/contracts-core/guardrail-packs.d.ts +41 -0
- package/dist/contracts-core/guardrail-packs.js +2 -0
- package/dist/contracts-core/loop.d.ts +42 -0
- package/dist/contracts-core/provider.d.ts +25 -0
- package/dist/contracts-core/run-limits.d.ts +21 -0
- package/dist/contracts-core/session.d.ts +23 -5
- package/dist/contracts-core/session.js +21 -2
- package/dist/contracts-core/usage.d.ts +40 -0
- package/dist/contracts-core/usage.js +8 -0
- package/dist/contracts-core.d.ts +2 -0
- package/dist/contracts-core.js +2 -0
- package/dist/contracts-protocol.d.ts +90 -4
- package/dist/contracts-run-state.d.ts +82 -6
- package/dist/evidence-grounding.d.ts +29 -0
- package/dist/evidence-grounding.js +162 -0
- package/dist/guardrail-packs/coding-standard.d.ts +3 -0
- package/dist/guardrail-packs/coding-standard.js +63 -0
- package/dist/guardrail-packs/destructive-commands.d.ts +3 -0
- package/dist/guardrail-packs/destructive-commands.js +46 -0
- package/dist/guardrail-packs/errors.d.ts +7 -0
- package/dist/guardrail-packs/errors.js +9 -0
- package/dist/guardrail-packs/index.d.ts +4 -0
- package/dist/guardrail-packs/index.js +15 -0
- package/dist/guardrail-packs/secrets-hygiene.d.ts +3 -0
- package/dist/guardrail-packs/secrets-hygiene.js +23 -0
- package/dist/guardrail-packs/types.d.ts +16 -0
- package/dist/guardrail-packs/types.js +2 -0
- package/dist/guardrail-packs/validation-respect.d.ts +3 -0
- package/dist/guardrail-packs/validation-respect.js +53 -0
- package/dist/guardrails.d.ts +20 -1
- package/dist/guardrails.js +268 -0
- package/dist/host-composition.d.ts +13 -0
- package/dist/host-composition.js +33 -2
- package/dist/index.d.ts +19 -10
- package/dist/index.js +11 -6
- package/dist/input.d.ts +8 -1
- package/dist/input.js +68 -6
- package/dist/middleware.d.ts +37 -2
- package/dist/middleware.js +41 -0
- package/dist/node/session-store-jsonl.js +18 -3
- package/dist/observability.js +6 -0
- package/dist/provider-events.d.ts +11 -3
- package/dist/provider-events.js +62 -4
- package/dist/providers/openai-compatible.js +6 -3
- package/dist/providers/transport.d.ts +3 -1
- package/dist/providers/transport.js +36 -0
- package/dist/redaction.js +18 -2
- package/dist/run-bundle.d.ts +89 -0
- package/dist/run-bundle.js +150 -0
- package/dist/run-limits.d.ts +11 -1
- package/dist/run-limits.js +46 -0
- package/dist/session-stores.d.ts +12 -1
- package/dist/session-stores.js +21 -4
- package/dist/testing/agent-event-source-conformance.js +41 -2
- package/dist/testing/prefix-stability-conformance.d.ts +30 -0
- package/dist/testing/prefix-stability-conformance.js +104 -0
- package/dist/testing/session-store-conformance.d.ts +3 -2
- package/dist/testing/session-store-conformance.js +48 -0
- package/dist/testing/state-concurrency-conformance.js +5 -12
- package/dist/tools.d.ts +5 -0
- package/dist/tools.js +11 -3
- package/dist/usage-estimation.d.ts +29 -0
- package/dist/usage-estimation.js +79 -0
- package/docs/ag-ui.md +5 -0
- package/docs/agent-events.md +68 -1
- package/docs/agent-loops.md +33 -0
- package/docs/agent-session-runtime.md +5 -3
- package/docs/attention-compiler.md +89 -8
- package/docs/coding-agent-tools.md +1 -1
- package/docs/coding-security.md +1 -0
- package/docs/coding-tools.md +0 -1
- package/docs/compaction-and-retry.md +1 -1
- package/docs/compaction-observational-memory.md +34 -7
- package/docs/connected-apps.md +116 -0
- package/docs/context-and-skills.md +13 -0
- package/docs/core.md +1 -1
- package/docs/diagrams.md +6 -6
- package/docs/document-reader.md +9 -9
- package/docs/documents.md +32 -11
- package/docs/durable-runs.md +129 -0
- package/docs/embeddings.md +5 -0
- package/docs/enterprise-postgres-state.md +4 -0
- package/docs/evaluations.md +5 -0
- package/docs/execution-timeline.md +84 -1
- package/docs/guardrails.md +71 -2
- package/docs/history/079-messaging-primitive-review.md +391 -0
- package/docs/history/080-messaging-followon-primitive-review.md +234 -0
- package/docs/history/081-connected-apps-primitive-review.md +74 -0
- package/docs/history/083-prism-work-primitive-review.md +84 -0
- package/docs/history/084-primitive-review.md +96 -0
- package/docs/history/085-honesty-and-cut-primitive-review.md +91 -0
- package/docs/history/README.md +5 -0
- package/docs/history/release-handoffs.md +38 -0
- package/docs/host-compositions.md +8 -6
- package/docs/host-security.md +2 -2
- package/docs/index.md +66 -29
- package/docs/input-and-prompt-assembly.md +3 -3
- package/docs/knowledge-sync.md +4 -0
- package/docs/live-testing.md +5 -3
- package/docs/mcp-tools.md +1 -0
- package/docs/messaging-channel-operations.md +166 -0
- package/docs/messaging-channels.md +150 -0
- package/docs/middleware-hooks.md +38 -2
- package/docs/migrate-to-0.8.md +124 -0
- package/docs/migrate-to-0.9.md +210 -0
- package/docs/migration.md +43 -0
- package/docs/model-registry.md +12 -2
- package/docs/multi-agent-patterns.md +25 -2
- package/docs/node-jsonl-session-store.md +7 -1
- package/docs/observability.md +7 -3
- package/docs/openapi-tools.md +1 -1
- package/docs/operations.md +1 -3
- package/docs/options-index.md +36 -3
- package/docs/peer-dependencies.md +6 -6
- package/docs/policy-and-audit.md +13 -1
- package/docs/postgres-persistence.md +1 -1
- package/docs/prefix-stability-conformance.md +93 -0
- package/docs/provider-caching.md +4 -4
- package/docs/provider-conformance.md +16 -0
- package/docs/provider-layer.md +2 -2
- package/docs/provider-packages.md +20 -20
- package/docs/providers/neuralwatt.md +5 -1
- package/docs/public-contracts.md +2 -2
- package/docs/rag.md +102 -4
- package/docs/release-and-install.md +55 -47
- package/docs/run-bundle.md +92 -0
- package/docs/runs-and-usage.md +57 -6
- package/docs/scoped-agent-memory.md +262 -0
- package/docs/server.md +2 -0
- package/docs/session-store-conformance.md +1 -2
- package/docs/session-stores.md +17 -17
- package/docs/sheets.md +9 -9
- package/docs/signal-channel.md +112 -0
- package/docs/speech.md +5 -1
- package/docs/sqlite-persistence.md +1 -1
- package/docs/supervisors.md +32 -12
- package/docs/telegram-channel.md +157 -0
- package/docs/testing.md +2 -2
- package/docs/tools.md +17 -0
- package/docs/wiki.md +1 -1
- package/docs/work-artifacts-and-review.md +1 -1
- package/docs/work-connectors.md +9 -9
- package/docs/work-sandbox.md +115 -0
- package/docs/work-tools.md +38 -16
- package/docs/workflows.md +5 -0
- package/package.json +9 -3
- package/templates/business-worker/manifest.json +2 -1
- package/templates/business-worker/src/agent.ts.tmpl +1 -1
- package/templates/business-worker/src/tests/agent.test.ts.tmpl +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// ponytail: dependency-free conformance runner for prompt-cache prefix stability.
|
|
2
|
+
// The runner owns the fixture provider (network-free, deterministic) and fixture
|
|
3
|
+
// skills; everything else in `host` is the caller's production assembly — system
|
|
4
|
+
// prompt, context providers, prompt builder, middleware, disclosure settings.
|
|
5
|
+
// Throws plain Error; no test runner, no network, no credentials.
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { createAgent } from "../agent-session/create-agent.js";
|
|
8
|
+
import { providerDone, toolCallContent } from "../provider-events.js";
|
|
9
|
+
import { createLoadSkillTool } from "../skill-load.js";
|
|
10
|
+
import { createSkillRegistry } from "../skills.js";
|
|
11
|
+
/**
|
|
12
|
+
* Drive a real session through two staggered skill loads and assert that each
|
|
13
|
+
* provider request keeps a byte-identical leading prefix (messages **and** tool
|
|
14
|
+
* schemas) with its predecessor. Progressive disclosure appends a loaded body
|
|
15
|
+
* after the stable prefix, so the shared prefix stays intact; a host that
|
|
16
|
+
* rewrites the context block, the skill catalog, or any leading message per
|
|
17
|
+
* request fails with the offending request pair and the measured fraction.
|
|
18
|
+
*/
|
|
19
|
+
export async function runPrefixStabilityConformance(options) {
|
|
20
|
+
const { host, skills } = options;
|
|
21
|
+
const minContinuity = options.minContinuity ?? 0.95;
|
|
22
|
+
const [first, second] = skills;
|
|
23
|
+
assert.notEqual(first.name, second.name, "prefix stability conformance needs two distinct skills");
|
|
24
|
+
const bodies = [];
|
|
25
|
+
for (const skill of skills) {
|
|
26
|
+
const instructions = skill.instructions;
|
|
27
|
+
assert.ok(typeof instructions === "string" && instructions.length > 0, `prefix stability conformance skill ${skill.name} needs non-empty instructions`);
|
|
28
|
+
bodies.push(instructions);
|
|
29
|
+
}
|
|
30
|
+
const requests = [];
|
|
31
|
+
const registry = createSkillRegistry([...skills]);
|
|
32
|
+
const hostTools = host.tools && "list" in host.tools ? host.tools.list() : (host.tools ?? []);
|
|
33
|
+
const agent = createAgent({
|
|
34
|
+
...host,
|
|
35
|
+
skills: registry,
|
|
36
|
+
tools: [...hostTools, createLoadSkillTool({ registry })],
|
|
37
|
+
provider: fixtureProvider(requests, [first.name, second.name]),
|
|
38
|
+
});
|
|
39
|
+
const session = agent.createSession();
|
|
40
|
+
const [firstInput, secondInput] = options.inputs ?? ["Prefix stability turn one", "Prefix stability turn two"];
|
|
41
|
+
const runOptions = { activeSkills: [first.name, second.name], limits: { maxToolRounds: 1 } };
|
|
42
|
+
await session.run(firstInput, runOptions);
|
|
43
|
+
await session.run(secondInput, runOptions);
|
|
44
|
+
assert.equal(requests.length, 4, `prefix stability conformance expected 4 provider requests (2 per staggered turn), captured ${requests.length}`);
|
|
45
|
+
const serialized = requests.map(serializeRequest);
|
|
46
|
+
// Guard against a vacuous pass: both bodies must have been disclosed by the end.
|
|
47
|
+
const last = serialized.at(-1) ?? "";
|
|
48
|
+
for (const [index, skill] of skills.entries()) {
|
|
49
|
+
assert.ok(last.includes(bodies[index] ?? ""), `prefix stability conformance: skill ${skill.name} body never reached the provider request — progressive disclosure did not expand it`);
|
|
50
|
+
}
|
|
51
|
+
let observed = 1;
|
|
52
|
+
let previous = serialized.at(0) ?? "";
|
|
53
|
+
for (let index = 1; index < serialized.length; index += 1) {
|
|
54
|
+
const next = serialized[index] ?? "";
|
|
55
|
+
const fraction = sharedPrefixFraction(previous, next);
|
|
56
|
+
observed = Math.min(observed, fraction);
|
|
57
|
+
assert.ok(fraction >= minContinuity, `prefix stability conformance: request ${index} → ${index + 1} kept ${(fraction * 100).toFixed(1)}% of the previous provider prefix ` +
|
|
58
|
+
`(minimum ${(minContinuity * 100).toFixed(1)}%). Late skill bodies must append after the stable prefix; ` +
|
|
59
|
+
"recomposed context, an in-place skill-catalog rewrite, or any leading-message mutation invalidates it.");
|
|
60
|
+
previous = next;
|
|
61
|
+
}
|
|
62
|
+
return { requests: serialized.length, minContinuity: observed };
|
|
63
|
+
}
|
|
64
|
+
/** Fixture provider: turn 1 loads `skillNames[0]`, turn 2 loads `skillNames[1]`, everything else completes. */
|
|
65
|
+
function fixtureProvider(requests, skillNames) {
|
|
66
|
+
let call = 0;
|
|
67
|
+
return {
|
|
68
|
+
id: "prefix-stability-fixture",
|
|
69
|
+
async *generate(request) {
|
|
70
|
+
requests.push(request);
|
|
71
|
+
const index = call;
|
|
72
|
+
call += 1;
|
|
73
|
+
const skillName = skillNames[index >> 1];
|
|
74
|
+
if (index % 2 === 0 && skillName !== undefined) {
|
|
75
|
+
yield { type: "tool_call", call: toolCallContent(`prefix-stability-${index}`, "load_skill", { name: skillName }) };
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
yield providerDone();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Provider-visible payload only: messages plus the tool schema fields sent on the wire. */
|
|
83
|
+
function serializeRequest(request) {
|
|
84
|
+
// One JSON fragment per message/tool so a structural array boundary never reads as a byte
|
|
85
|
+
// divergence: an appended message list stays an exact prefix of the next request.
|
|
86
|
+
const parts = [
|
|
87
|
+
...(request.tools ?? []).map((tool) => JSON.stringify({ name: tool.name, description: tool.description, parameters: tool.parameters })),
|
|
88
|
+
...request.messages.map((message) => JSON.stringify(message)),
|
|
89
|
+
];
|
|
90
|
+
return parts.join("\n");
|
|
91
|
+
}
|
|
92
|
+
/** Byte-shared prefix as a fraction of the previous request, so a shrink is a cache miss. */
|
|
93
|
+
function sharedPrefixFraction(previous, next) {
|
|
94
|
+
const before = Buffer.from(previous, "utf8");
|
|
95
|
+
const after = Buffer.from(next, "utf8");
|
|
96
|
+
if (before.length === 0)
|
|
97
|
+
return 1;
|
|
98
|
+
const limit = Math.min(before.length, after.length);
|
|
99
|
+
let shared = 0;
|
|
100
|
+
while (shared < limit && before[shared] === after[shared])
|
|
101
|
+
shared += 1;
|
|
102
|
+
return shared / before.length;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=prefix-stability-conformance.js.map
|
|
@@ -11,8 +11,9 @@ export interface SessionStoreConformanceOptions {
|
|
|
11
11
|
*/
|
|
12
12
|
readonly exerciseReadBranchPath?: boolean;
|
|
13
13
|
/**
|
|
14
|
-
* When true, exercises optional `searchSessions
|
|
15
|
-
*
|
|
14
|
+
* When true, exercises optional `searchSessions`: invalid limit/query/kind rejection via
|
|
15
|
+
* `resolveSessionSearchQuery` semantics, empty page, limit cap, a written-message query
|
|
16
|
+
* round-trip (`entryId`/`runId`/`turn`/`snippet`), the `kind` filter, and ownership bounds.
|
|
16
17
|
* Skipped when the store does not implement `searchSessions`.
|
|
17
18
|
*/
|
|
18
19
|
readonly exerciseSearchSessions?: boolean;
|
|
@@ -113,6 +113,54 @@ async function assertSessionStoreSearchSessions(store) {
|
|
|
113
113
|
await reject(() => search({ limit: Number.NaN }), (error) => error instanceof TypeError, "searchSessions must reject NaN limit");
|
|
114
114
|
await reject(() => search({ limit: HARD_MAX_SESSION_SEARCH_LIMIT + 1 }), (error) => error instanceof TypeError, "searchSessions must reject oversize limit");
|
|
115
115
|
await reject(() => search({ query: "x".repeat(HARD_MAX_SESSION_SEARCH_QUERY_BYTES + 1) }), (error) => error instanceof TypeError, "searchSessions must reject oversize query string");
|
|
116
|
+
await reject(() => search({ kind: "not-an-entry-kind" }), (error) => error instanceof TypeError, "searchSessions must reject an unknown kind");
|
|
117
|
+
// Query round-trip: a written message must be findable and the hit must point at it.
|
|
118
|
+
const searchSessionId = "conformance-search";
|
|
119
|
+
const token = "zzconformancesearchtoken";
|
|
120
|
+
const matchedEntry = {
|
|
121
|
+
id: "conformance-search-entry",
|
|
122
|
+
sessionId: searchSessionId,
|
|
123
|
+
timestamp: "2026-01-01T00:00:05.000Z",
|
|
124
|
+
kind: "message",
|
|
125
|
+
runId: "conformance-run",
|
|
126
|
+
message: { role: "user", content: [{ type: "text", text: `${token} body text` }] },
|
|
127
|
+
};
|
|
128
|
+
await store.append(matchedEntry);
|
|
129
|
+
const found = await search({ query: token, limit: 5 });
|
|
130
|
+
const hit = found.items.find((item) => item.sessionId === searchSessionId);
|
|
131
|
+
if (!hit) {
|
|
132
|
+
throw new Error("searchSessions must find a session by matching message text");
|
|
133
|
+
}
|
|
134
|
+
if (hit.entryId !== matchedEntry.id) {
|
|
135
|
+
throw new Error(`searchSessions must point at the matched entry; got ${String(hit.entryId)}`);
|
|
136
|
+
}
|
|
137
|
+
if (hit.runId !== matchedEntry.runId) {
|
|
138
|
+
throw new Error("searchSessions must carry the matched entry runId");
|
|
139
|
+
}
|
|
140
|
+
if (typeof hit.snippet !== "string" || !hit.snippet.includes(token)) {
|
|
141
|
+
throw new Error("searchSessions snippet must contain the matched text");
|
|
142
|
+
}
|
|
143
|
+
if (!Number.isSafeInteger(hit.turn) || hit.turn < 1) {
|
|
144
|
+
throw new Error("searchSessions must carry a 1-based matched-entry turn index");
|
|
145
|
+
}
|
|
146
|
+
const annotationOnly = await search({ query: token, kind: "summary", limit: 5 });
|
|
147
|
+
if (annotationOnly.items.some((item) => item.sessionId === searchSessionId)) {
|
|
148
|
+
throw new Error("searchSessions kind filter must exclude non-matching entry kinds");
|
|
149
|
+
}
|
|
150
|
+
// One hit per session: a second matching entry must not add a second row for the same session.
|
|
151
|
+
await store.append({
|
|
152
|
+
id: "conformance-search-entry-2",
|
|
153
|
+
parentId: matchedEntry.id,
|
|
154
|
+
sessionId: searchSessionId,
|
|
155
|
+
timestamp: "2026-01-01T00:00:06.000Z",
|
|
156
|
+
kind: "summary",
|
|
157
|
+
summary: `${token} recap`,
|
|
158
|
+
});
|
|
159
|
+
const deduped = await search({ query: token, limit: 5 });
|
|
160
|
+
const sessionHits = deduped.items.filter((item) => item.sessionId === searchSessionId);
|
|
161
|
+
if (sessionHits.length !== 1) {
|
|
162
|
+
throw new Error(`searchSessions must return one hit per session; got ${sessionHits.length}`);
|
|
163
|
+
}
|
|
116
164
|
const empty = await search(resolveSessionSearchQuery({
|
|
117
165
|
workspaceRoot: "__prism_conformance_empty__",
|
|
118
166
|
limit: 5,
|
|
@@ -68,7 +68,11 @@ async function checkpointCasProbe(checkpoints) {
|
|
|
68
68
|
const loaded = await checkpoints.loadCheckpoint(key);
|
|
69
69
|
assert.equal(loaded?.version, 4, "winning version must persist");
|
|
70
70
|
assert.equal(loaded?.fencingToken, 6, "winning fence must persist");
|
|
71
|
-
|
|
71
|
+
const foreign = await checkpoints.loadCheckpoint({ ...key, tenantId: "tenant-b" });
|
|
72
|
+
assert.equal(foreign, null, "a foreign checkpoint lands as a miss, not an ownership-shaped existence oracle");
|
|
73
|
+
await assertRejectsCode(() => checkpoints.saveCheckpoint({ ...key, tenantId: "tenant-b", version: 99, expectedVersion: 0, value: { evil: true } }), "ERR_PRISM_CHECKPOINT_CONFLICT", "a foreign checkpoint save must fail closed as a CAS conflict");
|
|
74
|
+
const intact = await checkpoints.loadCheckpoint(key);
|
|
75
|
+
assert.equal(intact?.version, 4, "a foreign writer must not disturb the owner's record");
|
|
72
76
|
}
|
|
73
77
|
/**
|
|
74
78
|
* Approval determinism: concurrent approve/deny of the same pending decision
|
|
@@ -334,15 +338,4 @@ async function assertRejectsCode(action, code, message) {
|
|
|
334
338
|
}
|
|
335
339
|
throw new Error(`${message}; expected a rejection with code ${code}`);
|
|
336
340
|
}
|
|
337
|
-
async function assertRejects(action, pattern, message) {
|
|
338
|
-
try {
|
|
339
|
-
await action();
|
|
340
|
-
}
|
|
341
|
-
catch (error) {
|
|
342
|
-
if (pattern.test(String(error)))
|
|
343
|
-
return;
|
|
344
|
-
throw new Error(`${message}; rejection did not match ${pattern}: ${String(error)}`);
|
|
345
|
-
}
|
|
346
|
-
throw new Error(`${message}; expected a rejection matching ${pattern}`);
|
|
347
|
-
}
|
|
348
341
|
//# sourceMappingURL=state-concurrency-conformance.js.map
|
package/dist/tools.d.ts
CHANGED
|
@@ -58,6 +58,11 @@ export declare function createToolRegistry(tools?: readonly ToolDefinition[], op
|
|
|
58
58
|
export declare function filterTools(tools: readonly ToolDefinition[], filter?: ToolFilterInput): readonly ToolDefinition[];
|
|
59
59
|
/** Cap matches tool-search index; run allow-lists never exceed the disclosed set. */
|
|
60
60
|
export declare const HARD_RUN_TOOL_NAMES = 1024;
|
|
61
|
+
/** Restrictive clamp: keep listed order; names outside the grant are dropped (not thrown). */
|
|
62
|
+
export declare function clampTurnToolNames(listed: readonly ToolDefinition[], requested: readonly string[]): {
|
|
63
|
+
readonly tools: readonly ToolDefinition[];
|
|
64
|
+
readonly dropped: readonly string[];
|
|
65
|
+
};
|
|
61
66
|
/**
|
|
62
67
|
* Per-run allow-list. Omitted grant → unchanged list. Checkpointed grant cannot widen.
|
|
63
68
|
* Fresh unknown names fail closed; resume drops names the current registry no longer has.
|
package/dist/tools.js
CHANGED
|
@@ -61,15 +61,15 @@ export function filterTools(tools, filter) {
|
|
|
61
61
|
/** Cap matches tool-search index; run allow-lists never exceed the disclosed set. */
|
|
62
62
|
export const HARD_RUN_TOOL_NAMES = 1024;
|
|
63
63
|
const MAX_RUN_TOOL_NAME_CHARS = 256;
|
|
64
|
-
function assertRunToolNames(names) {
|
|
64
|
+
function assertRunToolNames(names, label = "RunOptions.toolNames") {
|
|
65
65
|
if (names.length > HARD_RUN_TOOL_NAMES) {
|
|
66
|
-
throw new TypeError(
|
|
66
|
+
throw new TypeError(`${label} exceeds ${HARD_RUN_TOOL_NAMES} entries`);
|
|
67
67
|
}
|
|
68
68
|
const out = [];
|
|
69
69
|
const seen = new Set();
|
|
70
70
|
for (const name of names) {
|
|
71
71
|
if (typeof name !== "string" || name.length === 0 || name.length > MAX_RUN_TOOL_NAME_CHARS) {
|
|
72
|
-
throw new TypeError(
|
|
72
|
+
throw new TypeError(`${label} entries must be non-empty strings of at most ${MAX_RUN_TOOL_NAME_CHARS} characters`);
|
|
73
73
|
}
|
|
74
74
|
if (!seen.has(name)) {
|
|
75
75
|
seen.add(name);
|
|
@@ -78,6 +78,14 @@ function assertRunToolNames(names) {
|
|
|
78
78
|
}
|
|
79
79
|
return out;
|
|
80
80
|
}
|
|
81
|
+
/** Restrictive clamp: keep listed order; names outside the grant are dropped (not thrown). */
|
|
82
|
+
export function clampTurnToolNames(listed, requested) {
|
|
83
|
+
const names = assertRunToolNames(requested, "toolNarrowing");
|
|
84
|
+
const grant = new Set(listed.map((tool) => tool.name));
|
|
85
|
+
const dropped = names.filter((name) => !grant.has(name));
|
|
86
|
+
const allow = names.filter((name) => grant.has(name));
|
|
87
|
+
return { tools: allow.length === 0 ? [] : filterTools(listed, { allow }), dropped };
|
|
88
|
+
}
|
|
81
89
|
/**
|
|
82
90
|
* Per-run allow-list. Omitted grant → unchanged list. Checkpointed grant cannot widen.
|
|
83
91
|
* Fresh unknown names fail closed; resume drops names the current registry no longer has.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Model-family chars/token tables and the family text estimator (plan 091 Task 1).
|
|
2
|
+
*
|
|
3
|
+
* Pure and O(text length): no network, no I/O, no content retention. These are
|
|
4
|
+
* heuristics, not tokenizers — harnesses universally approximate. Reported usage
|
|
5
|
+
* always wins; estimates exist so missing usage is never shown as zero.
|
|
6
|
+
*
|
|
7
|
+
* Ratio provenance: `openai` is calibrated against `o200k_base` counts on the
|
|
8
|
+
* in-repo fixtures (`src/__tests__/usage-estimation.test.ts`); the other families
|
|
9
|
+
* use their published tokenizer guidance ranges and are intentionally rounded
|
|
10
|
+
* toward over-counting, because an overestimated context meter is safe while an
|
|
11
|
+
* underestimated one under-compacts. `unknown` is the most conservative table so
|
|
12
|
+
* an unidentified model can never look smaller than a known one.
|
|
13
|
+
*/
|
|
14
|
+
import type { ModelFamily, TokenEstimateConfidence } from "./contracts-core/usage.js";
|
|
15
|
+
/** Row of {@link MODEL_FAMILY_TOKENS}: prose chars per token, chat-template
|
|
16
|
+
* tokens added once per message, and the confidence label for the table. */
|
|
17
|
+
export interface ModelFamilyTokens {
|
|
18
|
+
readonly charsPerToken: number;
|
|
19
|
+
readonly perMessageOverhead: number;
|
|
20
|
+
readonly confidence: TokenEstimateConfidence;
|
|
21
|
+
}
|
|
22
|
+
/** Per-family chars/token tables (plan 091 Task 1). Estimates only, never billing. */
|
|
23
|
+
export declare const MODEL_FAMILY_TOKENS: Readonly<Record<ModelFamily, ModelFamilyTokens>>;
|
|
24
|
+
/** Resolve a model id (e.g. `"claude-sonnet-4.5"`), provider id, or family name
|
|
25
|
+
* to a table key. Unmatched input is `"unknown"` — never a throw. */
|
|
26
|
+
export declare function resolveModelFamily(model?: string): ModelFamily;
|
|
27
|
+
/** Estimate tokens for one flattened text under a family's ratios. The message
|
|
28
|
+
* level (`estimateMessageTokens`) owns per-message overhead; this is text-only. */
|
|
29
|
+
export declare function estimateTextTokensForFamily(text: string, modelFamily?: string): number;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** CJK ideographs/kana/hangul tokenize near 1.5 chars/token in modern family tokenizers. */
|
|
2
|
+
const CJK_CHARS_PER_TOKEN = 1.5;
|
|
3
|
+
/** Fenced code tokenizes worse than prose: code ratio = prose ratio * this factor. */
|
|
4
|
+
const CODE_RATIO_FACTOR = 0.88;
|
|
5
|
+
/** Per-family chars/token tables (plan 091 Task 1). Estimates only, never billing. */
|
|
6
|
+
export const MODEL_FAMILY_TOKENS = {
|
|
7
|
+
anthropic: { charsPerToken: 3.7, perMessageOverhead: 4, confidence: "medium" },
|
|
8
|
+
openai: { charsPerToken: 5.0, perMessageOverhead: 3, confidence: "medium" },
|
|
9
|
+
google: { charsPerToken: 3.9, perMessageOverhead: 4, confidence: "medium" },
|
|
10
|
+
deepseek: { charsPerToken: 3.8, perMessageOverhead: 4, confidence: "medium" },
|
|
11
|
+
"openrouter-generic": { charsPerToken: 4.4, perMessageOverhead: 4, confidence: "medium" },
|
|
12
|
+
mistral: { charsPerToken: 3.9, perMessageOverhead: 3, confidence: "medium" },
|
|
13
|
+
unknown: { charsPerToken: 3.5, perMessageOverhead: 6, confidence: "low" },
|
|
14
|
+
};
|
|
15
|
+
/** Model-id patterns per family. Family names themselves also resolve (see `resolveModelFamily`). */
|
|
16
|
+
const FAMILY_PATTERNS = [
|
|
17
|
+
["anthropic", /claude|anthropic/i],
|
|
18
|
+
["openai", /gpt-|openai|chatgpt|codex|^o[1-9]/i],
|
|
19
|
+
["google", /gemini|gemma|palm|google/i],
|
|
20
|
+
["deepseek", /deepseek/i],
|
|
21
|
+
["mistral", /mistral|mixtral|codestral|magistral|devstral|pixtral|ministral/i],
|
|
22
|
+
["openrouter-generic", /openrouter/i],
|
|
23
|
+
];
|
|
24
|
+
/** Resolve a model id (e.g. `"claude-sonnet-4.5"`), provider id, or family name
|
|
25
|
+
* to a table key. Unmatched input is `"unknown"` — never a throw. */
|
|
26
|
+
export function resolveModelFamily(model) {
|
|
27
|
+
if (typeof model !== "string")
|
|
28
|
+
return "unknown";
|
|
29
|
+
const id = model.trim();
|
|
30
|
+
if (id in MODEL_FAMILY_TOKENS)
|
|
31
|
+
return id;
|
|
32
|
+
for (const [family, pattern] of FAMILY_PATTERNS) {
|
|
33
|
+
if (pattern.test(id))
|
|
34
|
+
return family;
|
|
35
|
+
}
|
|
36
|
+
return "unknown";
|
|
37
|
+
}
|
|
38
|
+
/** Estimate tokens for one flattened text under a family's ratios. The message
|
|
39
|
+
* level (`estimateMessageTokens`) owns per-message overhead; this is text-only. */
|
|
40
|
+
export function estimateTextTokensForFamily(text, modelFamily) {
|
|
41
|
+
const { charsPerToken } = MODEL_FAMILY_TOKENS[resolveModelFamily(modelFamily)];
|
|
42
|
+
let cjk = 0;
|
|
43
|
+
for (let index = 0; index < text.length;) {
|
|
44
|
+
const codePoint = text.codePointAt(index) ?? 0; // unreachable 0: index < length; avoids a non-null assertion
|
|
45
|
+
if (isCjkCodePoint(codePoint))
|
|
46
|
+
cjk += 1;
|
|
47
|
+
index += codePoint > 0xffff ? 2 : 1;
|
|
48
|
+
}
|
|
49
|
+
const code = fencedChars(text);
|
|
50
|
+
const other = Math.max(0, text.length - cjk - code);
|
|
51
|
+
return Math.ceil(cjk / CJK_CHARS_PER_TOKEN + code / (charsPerToken * CODE_RATIO_FACTOR) + other / charsPerToken);
|
|
52
|
+
}
|
|
53
|
+
/** Length of the characters enclosed by ``` fence pairs (unclosed fence runs to the end). */
|
|
54
|
+
function fencedChars(text) {
|
|
55
|
+
let total = 0;
|
|
56
|
+
let index = text.indexOf("```");
|
|
57
|
+
while (index !== -1) {
|
|
58
|
+
const end = text.indexOf("```", index + 3);
|
|
59
|
+
if (end === -1) {
|
|
60
|
+
total += text.length - index;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
total += end + 3 - index;
|
|
64
|
+
index = text.indexOf("```", end + 3);
|
|
65
|
+
}
|
|
66
|
+
return total;
|
|
67
|
+
}
|
|
68
|
+
/** Han, kana, hangul, CJK punctuation/fullwidth, and ext-B+ ideograph ranges. */
|
|
69
|
+
function isCjkCodePoint(codePoint) {
|
|
70
|
+
return ((codePoint >= 0x3000 && codePoint <= 0x303f) ||
|
|
71
|
+
(codePoint >= 0x3040 && codePoint <= 0x30ff) ||
|
|
72
|
+
(codePoint >= 0x3400 && codePoint <= 0x4dbf) ||
|
|
73
|
+
(codePoint >= 0x4e00 && codePoint <= 0x9fff) ||
|
|
74
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7af) ||
|
|
75
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
76
|
+
(codePoint >= 0xff00 && codePoint <= 0xffef) ||
|
|
77
|
+
(codePoint >= 0x20000 && codePoint <= 0x2fa1f));
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=usage-estimation.js.map
|
package/docs/ag-ui.md
CHANGED
|
@@ -31,6 +31,7 @@ npm install @arnilo/prism @arnilo/prism-ag-ui
|
|
|
31
31
|
| `authorize` | Rebinds untrusted AG-UI thread/run selectors to host ownership on every request. `false` returns 403. |
|
|
32
32
|
| `sessionFactory` | Returns an authorized Prism `AgentSession`; it receives only host-approved `AgUiPreparedInput`, never raw client tools/state. |
|
|
33
33
|
| `input.project` | Opts into full `RunAgentInput`; turns bounded, still-untrusted history, state, context, forwarded props, media, and lineage into host-selected Prism `Message` values. Omit for legacy final-text mode. |
|
|
34
|
+
| `inputPolicy.clientState` (`AgUiInputPolicyOptions`) | `"honor"` (default) passes validated client `state`/`tools` to `input.project`. `"ignore"` validates the same envelope for shape and bounds, then discards both fields, so input comes only from the server session and projector; an unknown value fails at construction with `ERR_PRISM_AG_UI_INPUT` instead of silently honoring the client. |
|
|
34
35
|
| `input.frontendTools` | Explicitly selects client-side handoffs. Returned names must be request-tool subset; adapter never turns JSON tool declarations into Prism `ToolDefinition`s. |
|
|
35
36
|
| `mcp` | Optional `createAgUiMcpAdapter({ bridge, select })`; host selects reviewed `bridge.tools`, then `sessionFactory` receives them as `input.serverTools`. Normal Prism dispatch/loop remains sole executor. |
|
|
36
37
|
| `a2a` | Optional `createAgUiA2AAdapter({ client, select, correlate })`; verified remote A2A task stream replaces this handler's local session only. Host selection/correlation binds each remote task to ownership. |
|
|
@@ -44,6 +45,10 @@ npm install @arnilo/prism @arnilo/prism-ag-ui
|
|
|
44
45
|
|
|
45
46
|
The handler accepts only `POST` JSON validated with official AG-UI `RunAgentInputSchema`. Every aggregate is bounded before a callback runs. With no `input.project`, it preserves compatibility: final text user message only; non-empty state or frontend tools fail before authorization/session lookup. With a projector, all current roles/history, context, state, forwarded props, multimodal parts, parent lineage, and tool-result continuations are available as untrusted input. The projector must apply Prism media URL/SSRF/MIME policy before forwarding media. Start a run with no `resume` and no `?cursor=`; replay supplies `?cursor=`.
|
|
46
47
|
|
|
48
|
+
### Server-authoritative input (`inputPolicy.clientState: "ignore"`)
|
|
49
|
+
|
|
50
|
+
An official browser client posts its own projection: `initialState` becomes the request `state` and `runAgent({ tools })` supplies a tool list. Hosts that keep projection and tools on the server had two options — relay/rewrite every request, or reject state posts and break every browser run. `inputPolicy: { clientState: "ignore" }` is the third: the posted envelope is still schema-validated and bounded (so malformed, oversized, and poisonous payloads fail exactly as before, `400`/`413`), and then client `state` and `tools` are dropped before authorization, `coWorkContext`, `mcp.prepare`, `input.project`, and `defaultAgUiInput` see the input. Input is derived solely from the server session and the host projector, so a browser posting full state gets a normal run whose projection and tool list are the server's own. `AgUiPreparedInput.clientState` reports which policy produced the payload, `frontendTools` stays empty, and `capabilities.tools.clientProvided` is refused under `"ignore"` because the handler never hands client tools to a session. `state`, `context`, and `forwardedProps` reaching the projector under `"honor"` remain untrusted: the projector is still the only authority.
|
|
51
|
+
|
|
47
52
|
## Outputs / response / events
|
|
48
53
|
|
|
49
54
|
The handler returns `text/event-stream`, one `data: <AG-UI event>\n\n` frame per output. Mapper lifecycle is ordered: `RUN_*`, `STEP_*`, `TEXT_MESSAGE_*`, and `TOOL_CALL_*` are deterministic Prism mappings. Host projectors may additionally prove and emit `STATE_SNAPSHOT`/`STATE_DELTA`, `MESSAGES_SNAPSHOT`, `ACTIVITY_*`, current `REASONING_*`, `RAW`, and named `CUSTOM` values.
|
package/docs/agent-events.md
CHANGED
|
@@ -24,6 +24,8 @@ Event records preserve emission order within a run because the runtime drains pe
|
|
|
24
24
|
|
|
25
25
|
`AgentEventSource` (`createMemoryAgentEventSource` / `persistence.events` on PostgreSQL) appends, pages, and subscribes with opaque ownership-bound cursors. `subscribe` registers wake interest before replaying history so replay-to-live handoff has no gap. Delivery is at-least-once; consumers dedupe `record.id`. PostgreSQL uses transactional sequence allocation plus `LISTEN`/`NOTIFY` wakeups with polling fallback. Transport adapters (server SSE `Last-Event-ID`, AG-UI, A2A `afterEventId`) map source envelopes only — they do not invent private replay loops. This is not exactly-once.
|
|
26
26
|
|
|
27
|
+
Exactly three event types are terminal — `agent_finished`, `agent_denied`, and `error` — and one exported predicate answers the question for every consumer: `isTerminalAgentEventType(type)`. The memory, NATS, and Postgres sources, AG-UI replay, the A2A stream break, AG-UI `filterRun`, and conversation replay all route through it, so pages, subscriptions, and replays end on the same set. Attribution records such as `run_limit_exceeded` and `budget_exhausted` are not terminal (see [run limit events](#run-limit-events)).
|
|
28
|
+
|
|
27
29
|
### Placement (FR-7 answer, 0.0.26)
|
|
28
30
|
|
|
29
31
|
The durable `AgentEventSource` **stays in `@arnilo/prism-core/sessions/postgres`** for the 0.0.26 line and is importable from the package root (FR-6):
|
|
@@ -72,12 +74,15 @@ The `AgentEvent` union (grouped by concern):
|
|
|
72
74
|
| --- | --- |
|
|
73
75
|
| Agent lifecycle | `agent_started`, `agent_suspended`, `agent_resumed`, `agent_denied`, `agent_finished` |
|
|
74
76
|
| Turns | `turn_started`, `turn_finished` |
|
|
77
|
+
| Deterministic turns | `deterministic_turn` |
|
|
75
78
|
| Provider turns | `provider_turn_started`, `provider_turn_finished` |
|
|
76
79
|
| Assistant messages | `message_started`, `message_delta`, `message_finished` |
|
|
77
80
|
| Delegated agents | `delegated_agent_step` |
|
|
78
81
|
| Tool execution | `tool_execution_started`, `tool_execution_progress`, `tool_execution_finished`, `tool_execution_error`, `tool_execution_blocked` |
|
|
82
|
+
| Tool narrowing | `tool_narrowing_clamped` |
|
|
79
83
|
| Guardrails | `guardrail_decision` |
|
|
80
84
|
| Queue/subscribers | `queue_updated`, `event_subscriber_overflow`, `steer_rejected` |
|
|
85
|
+
| Run limits | `run_limit_exceeded`, `budget_exhausted` |
|
|
81
86
|
| Compaction | `compaction_started`, `compaction_finished` |
|
|
82
87
|
| Retry | `retry_scheduled` |
|
|
83
88
|
| Artifacts | `artifact_validation_started`, `artifact_validation_finished`, `artifact_revision_started`, `artifact_finished`, `artifact_failed` |
|
|
@@ -107,6 +112,8 @@ Adapters should call `createDelegatedAgentStep({ sessionId, runId, adapterId, ex
|
|
|
107
112
|
|
|
108
113
|
Coding hosts call `observeSupervisorLifecycle(supervisor, { onEvent, delegatedAgentStep })` to turn supervisor milestones into `subagent_started` / `subagent_stopped` coding lifecycle events. Both carry only redacted `childId`, `delegationId`, and `depth`; stopped events add terminal `AgentRunStatus`. Supplying `delegatedAgentStep` emits the bounded `delegated_agent_step` records AG-UI already maps. Child inputs, outputs, paths, and delegation error text never cross either bridge.
|
|
109
114
|
|
|
115
|
+
Supervisor child reporting is opt-in per child (`SupervisorChild.policy.report` ceiling; a request can only lower it). With `report: "milestones"` the supervisor publishes `child_milestone` (`childId`, `delegationId`, `depth`, `turn`, redacted `childEvent`) at the configured `milestone.everyTurns` cadence or host predicate; with `report: "stream"` it publishes `delegation_child_event` for every per-turn provider/tool/turn child event (never per-token `message_delta`). Both are redacted, count/byte-capped, and rate-coalesced (`delegation_child_events_coalesced` reports dropped events); the cap marker is `delegation_child_events_capped`. `child_failed` carries failure attribution for any child that died on an error or a limit: the redacted `reason`, the terminal `status`/`stopReason`, and the plan-086/087 `RunLimitBreach` in `limit` when a configured ceiling fired. Host cancels, policy denials, and hook rejections are not failures and never emit it. Hosts that want recovery counters rather than events read `supervisor.summary()` (`attempts`, `retries`, `failures`, `failureRadius`, `outcome` per child). Child events stay on the supervisor stream unless the host passes `childEventSink`, which receives the identical payload tagged with `child: { childId, delegationId, depth }` (`ChildEventOrigin`) for routing onto a parent session stream; they are not native `AgentEvent`s of the parent session, and hosts that surface them there re-attach the parent `sessionId`/`runId` themselves if needed.
|
|
116
|
+
|
|
110
117
|
`message_delta.content.type === "tool_call_delta"` carries `{ index, id?, name?, argumentsText? }`. Treat it as a streaming fragment. The runtime reconstructs and persists a final `tool_call` before executing tools. Deltas missing `id`/`name` at stream end fail the provider turn with `ErrorInfo.code: "incomplete_delta"` (typed `ProviderTransportError`); they never throw a bare `Error`. Malformed JSON with id+name present recovers as a blocked tool result (`invalid_json_arguments`) instead.
|
|
111
118
|
|
|
112
119
|
Tool execution events:
|
|
@@ -118,6 +125,7 @@ Tool execution events:
|
|
|
118
125
|
| `tool_execution_finished` | `sessionId`, `runId`, `result: ToolResult`, `metadata: ToolExecutionMetadata` |
|
|
119
126
|
| `tool_execution_error` | `sessionId`, `runId`, `call: ToolCallContent`, `error: ErrorInfo`, `metadata: ToolExecutionMetadata` |
|
|
120
127
|
| `tool_execution_blocked` | `sessionId`, `runId`, `toolCallId`, `name`, `reason: string`, `error: ErrorInfo`, `metadata: ToolExecutionMetadata` |
|
|
128
|
+
| `tool_narrowing_clamped` | `sessionId`, `runId`, `turn`, `dropped: readonly string[]` (names the host returned outside the run grant; no tool args) |
|
|
121
129
|
|
|
122
130
|
Guardrail events:
|
|
123
131
|
|
|
@@ -139,12 +147,71 @@ Queue / subscriber / compaction / retry / provider events:
|
|
|
139
147
|
| `attention_compiled` | `sessionId`, `runId?`, `used: number`, `usedAfter: number`, `inputCap: number`, `triggerRatio: number`, `droppedThinkingTurns: number`, `stubbedToolResults: number`, `stubbedBytes: number`, `truncated: boolean` — one per mutated turn of the opt-in [attention compiler](attention-compiler.md); counts only, never message text |
|
|
140
148
|
| `retry_scheduled` | `sessionId`, `runId`, `attempt: number`, `delayMs: number`, `error: ErrorInfo` |
|
|
141
149
|
|
|
150
|
+
### Run limit events
|
|
151
|
+
|
|
152
|
+
Terminal attribution — see [Runs and usage § Run limits](runs-and-usage.md#run-limits).
|
|
153
|
+
|
|
154
|
+
| Variant | Fields |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| `run_limit_exceeded` | `sessionId`, `runId`, `breach: RunLimitBreach` (`limit`, `maximum`, `observed`, optional `currency`) — emitted once, when an axis first exceeds its cap |
|
|
157
|
+
| `budget_exhausted` | `sessionId`, `runId`, `limit: RunLimitName`, `consumed: { turns, inputTokens, providerAttempts, requestBytes }`, `closestOtherAxes: [{ axis, usedRatio }]`, `recentToolCalls: [{ id, name, argHash }]` |
|
|
158
|
+
|
|
159
|
+
`budget_exhausted` is the terminal attribution for a run that died on a limit: it is emitted once per
|
|
160
|
+
limit death, before the terminal `error` event and the finish `RunRecord`. A limit death therefore
|
|
161
|
+
delivers three records in order — `run_limit_exceeded` (breach), `budget_exhausted` (attribution),
|
|
162
|
+
then the terminal `error` — and a page, subscription, or replay stays open across the first two:
|
|
163
|
+
keep reading until the stream ends rather than stopping at the first breach record. `limit` names the axis that fired
|
|
164
|
+
(`maxTurns`, `maxInputTokens`, `maxCost`, …). `closestOtherAxes` is the three other finite product
|
|
165
|
+
axes with the highest `used / cap` ratio, so a host can answer "how close was everything else";
|
|
166
|
+
request/response byte axes stay out because their caps are per-frame, and `usedRatio` is clamped to
|
|
167
|
+
`[0, 1]`. `recentToolCalls` holds the last ten host tool calls dispatched in this run (in dispatch
|
|
168
|
+
order, reset at run start and after a durable resume) as id, name, and `argHash` —
|
|
169
|
+
`sha256:<64 hex>` over the canonicalized arguments, never the arguments themselves. `consumed`
|
|
170
|
+
counters are the run-lifetime tracker snapshot at exhaustion. Events stay counts and hashes only, so
|
|
171
|
+
no new redaction class is introduced. Both events project onto the [execution timeline](execution-timeline.md)
|
|
172
|
+
as `timeline.exhaustion` plus the `turns[i].stopReason` badges, with a one-line summary on
|
|
173
|
+
`summarizeTimeline().exhaustion`.
|
|
174
|
+
|
|
142
175
|
Provider turn events (metadata only — see [Observability](observability.md)):
|
|
143
176
|
|
|
144
177
|
| Variant | Fields |
|
|
145
178
|
| --- | --- |
|
|
179
|
+
| `deterministic_turn` | `sessionId`, `runId`, `turn`, `middleware` — host middleware answered this turn without a provider request ([Middleware hooks](middleware-hooks.md#no-model-turns-beforeproviderturn)). Carries no `usage` key: provider accounting stays absent, never zero-filled. The same provenance reaches the persisted transcript as `message.metadata.deterministic = { middleware }` on the assistant `message_finished` message. |
|
|
146
180
|
| `provider_turn_started` | `sessionId`, `runId`, `turn`, `metadata: ProviderTurnMetadata` |
|
|
147
|
-
| `provider_turn_finished` | `sessionId`, `runId`, `turn`, `metadata` (includes `latencyMs` on finish), `usage?`, `error?` |
|
|
181
|
+
| `provider_turn_finished` | `sessionId`, `runId`, `turn`, `metadata` (includes `latencyMs`, `stopReason`, `budgets`, `tools`, and provider-reported `cache` metrics on finish), `usage?`, `error?` |
|
|
182
|
+
|
|
183
|
+
`provider_turn_finished.metadata.stopReason` names why that provider turn stopped, from one closed
|
|
184
|
+
taxonomy. Adapters map native wire values (`finish_reason`, `stop_reason`, `finishReason`, Converse
|
|
185
|
+
`stopReason`) through the shared `mapProviderStopReason` table, so a new provider value degrades to
|
|
186
|
+
`unknown` instead of failing a run; the normalized `done` provider event carries the same mapped
|
|
187
|
+
value when the adapter saw a native reason.
|
|
188
|
+
|
|
189
|
+
| `stopReason` | Meaning |
|
|
190
|
+
| --- | --- |
|
|
191
|
+
| `end_turn` | Model finished its answer (native `stop`, `end_turn`, `stop_sequence`, `STOP`, `completed`) |
|
|
192
|
+
| `tool_calls` | Turn requested host tools; also what a generic `end_turn` becomes when the turn produced tool calls |
|
|
193
|
+
| `max_output_tokens` | Output truncated at the provider's token cap (native `length`, `max_tokens`, `MAX_TOKENS`) |
|
|
194
|
+
| `content_filter` | Provider safety/refusal path (native `content_filter`, `refusal`, `SAFETY`, `guardrail_intervened`) |
|
|
195
|
+
| `abort` | The run or turn was aborted (host abort, steer soft interrupt) |
|
|
196
|
+
| `provider_error` | The turn failed with a provider error |
|
|
197
|
+
| `unknown` | Unmapped or absent native reason |
|
|
198
|
+
|
|
199
|
+
`provider_turn_finished.metadata.budgets` is an O(1) snapshot from the run limit tracker:
|
|
200
|
+
`{ inputTokens?, inputCap?, runInputBudget?, runInputUsed, turns, maxTurns }` — current-turn
|
|
201
|
+
provider-reported input tokens against the resolved per-request input cap, cumulative run input
|
|
202
|
+
against `limits.maxInputTokens`, and provider turns against `limits.maxTurns` (`null` when
|
|
203
|
+
disabled). Optional fields are absent when the provider reported no usage or no input cap can be
|
|
204
|
+
derived; hosts that ignore the fields are unaffected.
|
|
205
|
+
|
|
206
|
+
`provider_turn_started` / `provider_turn_finished` metadata includes `tools: { count, idsHash }` for the
|
|
207
|
+
effective menu sent on that request (after run scoping, per-turn `toolNarrowing`, and disclosure).
|
|
208
|
+
`idsHash` is `sha256:` plus 64 lowercase hex over `JSON.stringify(names)` in request order. Count and
|
|
209
|
+
hash only — never tool args, schemas, or descriptions. Identical consecutive subsets keep the same hash.
|
|
210
|
+
|
|
211
|
+
`provider_turn_finished.metadata.cache` is present only when the provider reported
|
|
212
|
+
`cacheReadTokens` or `cacheWriteTokens`: `{ cacheReadTokens?, cacheWriteTokens?, hitRate? }`.
|
|
213
|
+
`hitRate` is cache reads divided by reported input tokens. Unknown cache usage is absent, never
|
|
214
|
+
zero-filled; it contains counts only, never cache keys or prompt content.
|
|
148
215
|
|
|
149
216
|
Artifact validation/refinement events (emitted only by `generateValidateReviseLoop`; `singleShotLoop` emits zero artifact events):
|
|
150
217
|
|
package/docs/agent-loops.md
CHANGED
|
@@ -130,6 +130,39 @@ The snapshot is stored as `loopState: { name, revision, snapshot }` on the durab
|
|
|
130
130
|
|
|
131
131
|
A strategy returned by `generateValidateReviseLoop()` is safe to reuse across sequential runs. Its built-in state is scoped to `(sessionId, runId)`; a new non-restored run resets attempts, artifact phase, saved schema, and pending repair messages, while a restored run keeps the checkpointed state. Arbitrary custom strategies are not cloned or reset automatically.
|
|
132
132
|
|
|
133
|
+
## Turn policy
|
|
134
|
+
|
|
135
|
+
`RunOptions.turnPolicy` (`TurnPolicyOptions`) lets a host end a run **cleanly** at a provider-turn boundary — after the previous turn's tool results are persisted, before the next provider request (the same point `checkpointPolicy: "every-turn"` checkpoints at). This is the "stop when the agent has done enough" seam: an investigation that should stop at the first plan paint, a desk that stops after N turns, a policy that stops once a tool budget is spent.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
await session.run("Investigate the churn spike", {
|
|
139
|
+
turnPolicy: {
|
|
140
|
+
// Clean turn cap: reaching it stops the run instead of failing it.
|
|
141
|
+
maxTurns: 4,
|
|
142
|
+
// Consulted once per boundary; a stop ends the run as `succeeded`.
|
|
143
|
+
stop: (ctx) =>
|
|
144
|
+
ctx.turns >= 1 && ctx.toolCalls >= 1
|
|
145
|
+
? { action: "stop", reason: "l1-first-plan-paint" }
|
|
146
|
+
: { action: "continue" },
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
| `TurnBoundaryContext` field | Meaning |
|
|
152
|
+
| --- | --- |
|
|
153
|
+
| `sessionId`, `runId` | Run correlation. |
|
|
154
|
+
| `turn` | 1-based index of the provider turn this boundary precedes. |
|
|
155
|
+
| `turns` | Provider turns already completed (`turn - 1`; `0` at the first boundary). |
|
|
156
|
+
| `toolCalls` | Host tool calls dispatched so far in this run. |
|
|
157
|
+
| `usage` | Run-total usage so far, when the provider reported any. |
|
|
158
|
+
| `metadata` | Run metadata (never prompt text, tool arguments, or results). |
|
|
159
|
+
|
|
160
|
+
A `stop` decision is a **clean terminal outcome**, not an error or a limit breach: the run returns `status: "succeeded"` with `stopReason: "host_policy"` and `stopDetail` (the host's `reason`, ≤256 UTF-8 bytes, redacted). The same pair rides `agent_finished.finishReason`/`stopDetail`, the finish `RunRecord`, and the projected `ExecutionTimeline`. `turnPolicy.maxTurns` is a *clean* cap: it reports `stopReason: "turn_limit"` and, unlike a `limits.maxTurns` breach, never throws `AgentRunLimitError`. A run overlay may only narrow `limits.maxTurns` — widening throws before the first provider turn.
|
|
161
|
+
|
|
162
|
+
A policy stop stays **resumable**: with `runState: { checkpointPolicy: "every-turn" }` the terminal state keeps the run frontier, so `resumeAgentRun(..., { decision: "continue" })` continues from the boundary. Steers queued before the stop are already in the session history and reach the resumed leg exactly once. A `turnPolicy.maxTurns` stop is the exception — resuming it would re-stop on the first boundary. Resumed runs carry no `turnPolicy` (resume options are not run options), so a continued leg runs to its natural end unless the host stops it again.
|
|
163
|
+
|
|
164
|
+
The callback is synchronous and bounded, and is never called when `turnPolicy` is omitted: a run without a policy keeps its exact request stream. A callback that throws or returns a malformed decision fails the run closed with `ERR_PRISM_TURN_POLICY` (the boundary makes no provider call and the checkpoint stays fail-closed); a stopped run is never recorded as failed.
|
|
165
|
+
|
|
133
166
|
## Outputs / response / events
|
|
134
167
|
|
|
135
168
|
`AgentLoopStrategy.run(ctx)` returns `Promise<Usage | undefined>` as a fallback for custom loops. Core runtime independently accumulates every usage-bearing provider turn in O(turns), persists scoped turn/run rows, and emits `agent_finished` with the aggregate.
|
|
@@ -12,6 +12,7 @@ The agent/session runtime adds the minimal shared SDK surface for running provid
|
|
|
12
12
|
- `session.prompt(input, options)` → `AgentRunResult`
|
|
13
13
|
- `session.stream(input, options)` → owned-run `AsyncIterable<AgentEvent>`
|
|
14
14
|
- `session.compact(options?)`
|
|
15
|
+
- `session.contextMeter()` → `ContextMeter` (latest provider-turn input tokens, reported or labeled estimate, with cap/budget/ratio)
|
|
15
16
|
- `session.subscribe(options?)`
|
|
16
17
|
- `session.abort()`
|
|
17
18
|
- `session.entries()`
|
|
@@ -61,7 +62,7 @@ string | Message | readonly Message[]
|
|
|
61
62
|
|
|
62
63
|
`session.stream(input, options?)` subscribes first, starts exactly one run, yields only that run's events, and terminates when the run succeeds, fails, or aborts. Early consumer return aborts the owned run and releases the session. `SubscribeOptions.maxQueuedEvents` / `overflow` may be passed alongside `RunOptions`.
|
|
63
64
|
|
|
64
|
-
`resumeAgentRunStream(agent, ref, resume, options?)` does the same for one existing suspended durable run. It validates checkpoint ownership, revision/fingerprint, and `expectedVersion`, then subscribes before emitting `agent_started` / `agent_resumed` and resumed message/tool/terminal events. `AgentRunResumeStreamOptions` combines `AgentRunResumeOptions` (including the optional `onSession` observer seam a supervisor uses to attach a child event pump to the rebuilt session) with `
|
|
65
|
+
`resumeAgentRunStream(agent, ref, resume, options?)` does the same for one existing suspended durable run. It validates checkpoint ownership, revision/fingerprint, and `expectedVersion`, then subscribes before emitting `agent_started` / `agent_resumed` and resumed message/tool/terminal events. `AgentRunResumeStreamOptions` combines `AgentRunResumeOptions` (including the optional `onSession` observer seam a supervisor uses to attach a child event pump to the rebuilt session) with `maxQueuedEvents` and `overflow`; early return aborts only resumed execution. Since 0.8.0 (plan 080 Task 3), `AgentRunResumeOptions.signal` is inherited by both entrypoints, so `resumeAgentRun()` aborts a live resumed provider/tool turn the same way `resumeAgentRunStream()` does — checked before each preparation step and threaded into the resumed execution. It does not replay a claimed/dispatched tool, poll a ledger, or retain a worker. `createAgentRunLifecycle().resumeStream(ref, resume, request?)` adds the same behavior after host agent-capability resolution.
|
|
65
66
|
|
|
66
67
|
`session.subscribe(options?)` remains available for hosts that want a long-lived subscriber across runs. Subscribe before `run()` to observe that run's events. The consumer loop and `session.run()` must run concurrently (e.g. start the `for await` consumer, then `await Promise.all([consumer, session.run("Hi")])`): events are only emitted during a live run, so awaiting the subscribe loop before calling `run()` deadlocks. Prefer `session.stream()` when you only need one run's events. `SubscribeOptions.maxQueuedEvents` defaults to `1024` (minimum `1`) and caps events queued while the consumer is not awaiting `next()`. `SubscribeOptions.overflow` defaults to `"close"`; it clears queued payload events, delivers one `event_subscriber_overflow` notice to that subscriber, then closes it. `"drop_oldest"` keeps newest events; `"drop_newest"` ignores new events while full.
|
|
67
68
|
|
|
@@ -186,11 +187,12 @@ Set `runState` with a host-owned `CheckpointStore`, stable `definitionRevision`,
|
|
|
186
187
|
`resumeAgentRun` accepts exactly one of:
|
|
187
188
|
|
|
188
189
|
- `decision: "approve" | "deny"` — legacy single-approval path. `approve` allows every pending decision once; `deny` terminates the run as `denied`.
|
|
190
|
+
- `decision: "continue"` — crash recovery for a running-state checkpoint written by [`checkpointPolicy: "every-turn"`](durable-runs.md): resumes from the last provider-turn boundary without re-dispatching tools. It requires a running state and never bypasses a gate — a suspended run still needs `approve`/`deny` or a decision batch.
|
|
189
191
|
- `decisions: readonly RunDecision[]` — one atomic batch. Every entry validates against the recorded pending set (unknown/foreign `approvalId`, duplicates, stale `expectedVersion`, invalid outcomes fail the whole batch closed with `AgentDecisionError` and leave state and version untouched). Outcomes: `allow_once`, `allow_for_run`, `reject_once`, `reject_for_run`. `reject_*` continues the run with a blocked tool result carrying the bounded (2 KB) `reason`. `modifiedArguments` are revalidated (schema, then input guardrails; permission/trust re-run at dispatch) and produce a new arguments hash. `elicitation` payloads are validated against the pending decision's `elicitationSchema` (required keys plus the configured host validator) and resolve the suspended call without executing it. A batch deciding a strict subset persists the decided entries and re-suspends with the remainder pending at the bumped version.
|
|
190
192
|
|
|
191
193
|
`*_for_run` outcomes append a `StickyDecision` to the durable run state: later calls in the same run matching the scope exactly (all recorded fields) proceed or are blocked without a new suspension, policy still enforced at dispatch. Sticky decisions expire when the run reaches any terminal status. Caps: 32 pending decisions per run (hard 128), 64 sticky decisions (hard 256), 2 KB decision reasons, 16 KB elicitation payloads. Frontend adapters (such as AG-UI with `capabilities.humanInTheLoop.approveWithEdits`) and the server resume endpoint (`POST .../resume` with `modifiedArguments`) map human edits directly to `RunDecision` entries with `modifiedArguments` under single atomic CAS, revalidating tool parameter schemas and invalidating stale draft approvals.
|
|
192
194
|
|
|
193
|
-
**Runtime input validation (0.2.0, plan 020 Task 2).** Every public resume entrypoint (`resumeAgentRun`, `resumeAgentRunStream`, `AgentRunLifecycle.resume()`/`resumeStream()`) validates the complete resume input in core before any checkpoint read/write, agent resolution, subscription, or tool execution: a non-null object, positive safe-integer `expectedVersion`, exactly one of `decision`/`decisions`, legacy `decision` exactly `approve`/`deny`, and a non-empty batch ≤ 128 entries whose entries are objects with a bounded non-empty `approvalId`, a whitelisted outcome, an optional string `reason` within the 2 KB limit, and JSON-object `modifiedArguments`/`elicitation` within the 16 KB limit. Unknown legacy decisions (e.g. `"sideways"`) and malformed untyped batches fail closed with `AgentDecisionError` (`ERR_PRISM_DECISION_INVALID`/`..._LIMIT`/`..._DUPLICATE`) under a **no-side-effect guarantee**: zero checkpoint writes/CAS changes, zero tool calls, zero resumed events. This holds for plain-JavaScript and `as any` callers; the server's transport parser is defense in depth, not the security boundary. State-dependent checks (foreign/stale approval ids, scope, schema, policy) still run in the atomic batch resolver.
|
|
195
|
+
**Runtime input validation (0.2.0, plan 020 Task 2).** Every public resume entrypoint (`resumeAgentRun`, `resumeAgentRunStream`, `AgentRunLifecycle.resume()`/`resumeStream()`) validates the complete resume input in core before any checkpoint read/write, agent resolution, subscription, or tool execution: a non-null object, positive safe-integer `expectedVersion`, exactly one of `decision`/`decisions`, legacy `decision` exactly `approve`/`deny`/`continue`, and a non-empty batch ≤ 128 entries whose entries are objects with a bounded non-empty `approvalId`, a whitelisted outcome, an optional string `reason` within the 2 KB limit, and JSON-object `modifiedArguments`/`elicitation` within the 16 KB limit. Unknown legacy decisions (e.g. `"sideways"`) and malformed untyped batches fail closed with `AgentDecisionError` (`ERR_PRISM_DECISION_INVALID`/`..._LIMIT`/`..._DUPLICATE`) under a **no-side-effect guarantee**: zero checkpoint writes/CAS changes, zero tool calls, zero resumed events. This holds for plain-JavaScript and `as any` callers; the server's transport parser is defense in depth, not the security boundary. State-dependent checks (foreign/stale approval ids, scope, schema, policy) still run in the atomic batch resolver.
|
|
194
196
|
|
|
195
197
|
```ts
|
|
196
198
|
const result = await session.run("Publish draft", {
|
|
@@ -203,7 +205,7 @@ if (result.status === "suspended") {
|
|
|
203
205
|
}
|
|
204
206
|
```
|
|
205
207
|
|
|
206
|
-
Resume requires exact checkpoint ownership, version, agent fingerprint, and revision. The fingerprint hashes the agent id/name, `definitionRevision`, model, instructions, system-prompt contributions, skills (name/instructions/tool names), tool definitions (name/parameters/exclusive), guardrail definitions (name/stage/revision), and loop strategy — changing any of them without bumping `definitionRevision` fails resume closed instead of silently continuing with different agent semantics. Prism CAS-claims approval before work, rechecks normal guardrail/permission/validation/limit paths, and marks a pending tool dispatched before its side effect. `createAgentRunLifecycle()` wraps the same core path for server/MCP hosts: adapters pass only authorized ownership, status returns only `{ state, version }`, and `resolveAgent()` supplies current agent/revision. `resumeStream()` uses that same claim path and bounded subscriber, so adapters do not poll or duplicate resume logic. Remote restart requires both checkpoint and session stores to be durable. A crash after that mark is ambiguous and is never replayed automatically; use host tool idempotency keyed by `runId`/`toolCallId` or resolve it manually. Checkpoints contain bounded redacted state plus session/leaf references, never provider objects, callbacks, signals, credentials, or raw secrets. State is bounded at save by `runState.maxStateBytes` (default 256 KB, at most the 1 MB hard cap); load bounds against the 1 MB hard cap only, so state saved with a raised limit stays resumable while oversized records are still rejected. Since 0.1.3 (plan 015 Task 4), durable runs may opt in to session-state persistence with `persistSessionState: true` on both the run and resume options: the loaded-skill **name catalog** (≤64 names, ≤256 chars each) rides the checkpoint and is restored into the resumed session's `LoadedSkillSet`; skill **bodies are never persisted** and re-resolve from the live registry via `load_skill`. Since 0.1.6 (plan 018 closeout `checkpoint-bodies`), `includeSkillBodies: true` on BOTH the run and resume options additionally persists the exact loaded-skill **instructions** (`{name, instructions}` pairs, redacted at the checkpoint boundary like all state, ≤64 bodies / ≤256-char names / ≤262144-byte bodies / ≤1 MiB total) so resume re-renders them registry-independently — no `load_skill` round-trip and no dependence on the registry still serving the same text; `maxStateBytes` (default 256 KB) refuses oversize bodies with a recorded error, never silently truncates. Default off keeps the checkpoint shape byte-identical to 0.1.3. Since 0.7.0 (plan 074 P3), `persistSessionState: true` also carries the opt-in [attention compiler](attention-compiler.md)'s sticky frontier (`sessionState.attentionSticky`: 32-hex thinking keys plus tool-call ids, newest 256 of each, redacted like all state) so a resumed run keeps its thinking strips and tool stubs instead of re-deciding its first turn from the ratio; a malformed frontier is dropped entry by entry and never blocks a resume. Since 0.7.0, `onSession` hands the reconstructed session to a caller-supplied observer before the resumed run starts, so an observer (the supervisor's child-event pump) can subscribe while the run is still live; it is called for every resume outcome, a throw fails closed before any event or tool work, and the session is valid only for the duration of that resume. Built-in loop options are durable; custom `AgentLoopStrategy` instances are durable when they declare `snapshot`/`restore` hooks (see [Agent loops § Durable runs](agent-loops.md#durable-runs)) and reject before provider work otherwise.
|
|
208
|
+
Resume requires exact checkpoint ownership, version, agent fingerprint, and revision. A checkpoint load or delete under a non-matching ownership scope reads as absent (`null`), and a save against a foreign-owned record fails as a generic `ERR_PRISM_CHECKPOINT_CONFLICT` (plan 080 Task 3) — a tenant cannot distinguish “another tenant owns this key” from “missing”, and callers that relied on the old `Checkpoint ownership mismatch` throw now see the same miss they would for an unknown key. The fingerprint hashes the agent id/name, `definitionRevision`, model, instructions, system-prompt contributions, skills (name/instructions/tool names), tool definitions (name/parameters/exclusive), guardrail definitions (name/stage/revision), and loop strategy — changing any of them without bumping `definitionRevision` fails resume closed instead of silently continuing with different agent semantics. Prism CAS-claims approval before work, rechecks normal guardrail/permission/validation/limit paths, and marks a pending tool dispatched before its side effect. `createAgentRunLifecycle()` wraps the same core path for server/MCP hosts: adapters pass only authorized ownership, status returns only `{ state, version }`, and `resolveAgent()` supplies current agent/revision. `resumeStream()` uses that same claim path and bounded subscriber, so adapters do not poll or duplicate resume logic. Remote restart requires both checkpoint and session stores to be durable. A crash after that mark is ambiguous and is never replayed automatically; use host tool idempotency keyed by `runId`/`toolCallId` or resolve it manually. Checkpoints contain bounded redacted state plus session/leaf references, never provider objects, callbacks, signals, credentials, or raw secrets. State is bounded at save by `runState.maxStateBytes` (default 256 KB, at most the 1 MB hard cap); load bounds against the 1 MB hard cap only, so state saved with a raised limit stays resumable while oversized records are still rejected. Since 0.1.3 (plan 015 Task 4), durable runs may opt in to session-state persistence with `persistSessionState: true` on both the run and resume options: the loaded-skill **name catalog** (≤64 names, ≤256 chars each) rides the checkpoint and is restored into the resumed session's `LoadedSkillSet`; skill **bodies are never persisted** and re-resolve from the live registry via `load_skill`. Since 0.1.6 (plan 018 closeout `checkpoint-bodies`), `includeSkillBodies: true` on BOTH the run and resume options additionally persists the exact loaded-skill **instructions** (`{name, instructions}` pairs, redacted at the checkpoint boundary like all state, ≤64 bodies / ≤256-char names / ≤262144-byte bodies / ≤1 MiB total) so resume re-renders them registry-independently — no `load_skill` round-trip and no dependence on the registry still serving the same text; `maxStateBytes` (default 256 KB) refuses oversize bodies with a recorded error, never silently truncates. Default off keeps the checkpoint shape byte-identical to 0.1.3. Since 0.7.0 (plan 074 P3), `persistSessionState: true` also carries the opt-in [attention compiler](attention-compiler.md)'s sticky frontier (`sessionState.attentionSticky`: 32-hex thinking keys plus tool-call ids, newest 256 of each, redacted like all state) so a resumed run keeps its thinking strips and tool stubs instead of re-deciding its first turn from the ratio; a malformed frontier is dropped entry by entry and never blocks a resume. Since 0.7.0, `onSession` hands the reconstructed session to a caller-supplied observer before the resumed run starts, so an observer (the supervisor's child-event pump) can subscribe while the run is still live; it is called for every resume outcome, a throw fails closed before any event or tool work, and the session is valid only for the duration of that resume. Built-in loop options are durable; custom `AgentLoopStrategy` instances are durable when they declare `snapshot`/`restore` hooks (see [Agent loops § Durable runs](agent-loops.md#durable-runs)) and reject before provider work otherwise. For mid-run crash recovery (`checkpointPolicy: "every-turn"` plus `decision: "continue"`), see [Durable runs](durable-runs.md).
|
|
207
209
|
|
|
208
210
|
## Secure composition
|
|
209
211
|
|