@arnilo/prism 0.8.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 +38 -0
- package/README.md +11 -11
- package/dist/agent-approval.d.ts +11 -2
- 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 +63 -6
- package/dist/agent-run-state.d.ts +22 -2
- package/dist/agent-run-state.js +57 -5
- package/dist/agent-session/helpers.js +14 -0
- package/dist/agent-session/session/assemble.js +126 -24
- package/dist/agent-session/session/persist.d.ts +11 -0
- package/dist/agent-session/session/persist.js +37 -11
- package/dist/agent-session/session/provider-round.d.ts +14 -4
- package/dist/agent-session/session/provider-round.js +185 -19
- package/dist/agent-session/session/tool-round.js +20 -1
- package/dist/agent-session/session/types.d.ts +25 -2
- package/dist/agent-session/session.d.ts +38 -4
- package/dist/agent-session/session.js +76 -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/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 +10 -0
- package/dist/contracts-core/guardrail-packs.d.ts +41 -0
- package/dist/contracts-core/guardrail-packs.js +2 -0
- package/dist/contracts-core/provider.d.ts +25 -0
- package/dist/contracts-core/run-limits.d.ts +19 -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 +76 -2
- package/dist/contracts-run-state.d.ts +56 -1
- 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/index.d.ts +14 -9
- package/dist/index.js +9 -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 +8 -2
- package/dist/provider-events.js +60 -2
- package/dist/providers/openai-compatible.js +6 -3
- package/dist/run-bundle.js +2 -1
- 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/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/agent-events.md +68 -1
- package/docs/agent-session-runtime.md +1 -0
- package/docs/attention-compiler.md +89 -8
- package/docs/coding-agent-tools.md +1 -1
- package/docs/compaction-and-retry.md +1 -1
- package/docs/compaction-observational-memory.md +33 -6
- package/docs/durable-runs.md +42 -0
- package/docs/embeddings.md +5 -0
- package/docs/evaluations.md +5 -0
- package/docs/execution-timeline.md +78 -1
- package/docs/guardrails.md +38 -2
- package/docs/index.md +32 -13
- package/docs/input-and-prompt-assembly.md +3 -3
- package/docs/knowledge-sync.md +4 -0
- package/docs/middleware-hooks.md +38 -2
- package/docs/migrate-to-0.9.md +210 -0
- package/docs/migration.md +13 -0
- 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/options-index.md +2 -1
- package/docs/policy-and-audit.md +13 -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-packages.md +20 -20
- package/docs/public-contracts.md +2 -2
- package/docs/rag.md +101 -3
- package/docs/release-and-install.md +39 -37
- package/docs/runs-and-usage.md +43 -6
- package/docs/scoped-agent-memory.md +262 -0
- package/docs/session-store-conformance.md +1 -2
- package/docs/session-stores.md +17 -17
- package/docs/supervisors.md +32 -12
- package/docs/tools.md +17 -0
- package/docs/workflows.md +5 -0
- package/package.json +5 -1
|
@@ -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,
|
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/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
|
|
|
@@ -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()`
|
|
@@ -48,16 +48,17 @@ await session.run("cheap run", { attentionCompiler: { triggerRatio: 0.95, compac
|
|
|
48
48
|
The agent setting is resolved with the run's model at run start, before any provider turn, so a malformed setting or a widening overlay fails the run immediately instead of on the turn that crosses the ratio:
|
|
49
49
|
|
|
50
50
|
- **Allowed in the overlay:** `triggerRatio` / `compactRatio` at or above the agent setting, `keepLast` / `thinkingKeepTurns` at or below it, and extra `excludeTools` (unioned with the agent list, never removed).
|
|
51
|
-
- **Rejected:** a lower gate ratio, more protected rows, and `maxInputTokens` / `reserveTokens` — cap inputs are agent-config only, because moving
|
|
51
|
+
- **Rejected:** a lower gate ratio, more protected rows, and `maxInputTokens` / `reserveTokens` / `trigger` — cap inputs and fold axes are agent-config only, because moving either moves the gate itself. Raising `triggerRatio` at or above the agent's `compactRatio` needs `compactRatio` raised in the same overlay.
|
|
52
52
|
- **Enabling from a run is rejected:** a run may disable or relax the compiler, never switch it on where the agent config left it off.
|
|
53
53
|
|
|
54
|
-
The **sticky frontier is session-owned and created lazily** the first time an enabled run assembles a request: one `{ thinking, toolCallIds }` set pair per session, shared across runs, provider rounds, and branches, so a stub or strip made once stays applied even on a later under-ratio turn.
|
|
54
|
+
The **sticky frontier is session-owned and created lazily** the first time an enabled run assembles a request: one `{ thinking, toolCallIds }` set pair per session, shared across runs, provider rounds, and branches, so a stub or strip made once stays applied even on a later under-ratio turn. A durable run with `persistSessionState: true` writes its bounded snapshot into the checkpoint and restores it on resume, so a resumed process keeps its stubs instead of re-deciding its first turn from the ratio.
|
|
55
55
|
|
|
56
56
|
`AttentionCompilerOptions` (all optional):
|
|
57
57
|
|
|
58
58
|
| Field | Type | Default | Meaning |
|
|
59
59
|
| --- | --- | --- | --- |
|
|
60
|
-
| `triggerRatio` | `number` | `0.75` | Fraction of `inputCap` that enables mutation; must be in `(0, 1)` (exclusive). |
|
|
60
|
+
| `triggerRatio` | `number` | `0.75` | Fraction of `inputCap` that enables mutation; must be in `(0, 1)` (exclusive). The reference ratio the report carries and `compactRatio` is checked against. |
|
|
61
|
+
| `trigger` | `AttentionTriggerInput` | — | Fold axes (plan 086 T2). **Replaces** the `triggerRatio` axis when set; omitted keeps it alone, so behavior is unchanged. See [Trigger axes](#trigger-axes). |
|
|
61
62
|
| `compactRatio` | `number` | `0.9` | Where compaction should fire relative to the compiler; must exceed `triggerRatio`. |
|
|
62
63
|
| `thinkingKeepTurns` | `number` | `1` | Newest thinking-bearing assistant turns kept intact. |
|
|
63
64
|
| `keepLast` | `number` | `3` | Newest tool results kept full. |
|
|
@@ -71,8 +72,9 @@ The **sticky frontier is session-owned and created lazily** the first time an en
|
|
|
71
72
|
| --- | --- | --- |
|
|
72
73
|
| `model` | `{ limits?: ModelLimits }` | Source of `contextWindow` / `maxOutputTokens` when `maxInputTokens` is absent. |
|
|
73
74
|
| `compactionTrigger` | `CompactionTrigger` | Optional: validated here so an unknown trigger `type` fails at create time, not on the first turn. An `input_ratio` trigger must exceed `triggerRatio`. |
|
|
75
|
+
| `runInputBudget` | `number \| null` | Cumulative run input budget the `run_input_ratio` axis folds against — pass the resolved `RunLimits.maxInputTokens`. `null` or omitted means the run declares no budget, so that axis falls back to the input cap. Distinct from `maxInputTokens`, which caps a single request. |
|
|
74
76
|
|
|
75
|
-
**Public surface.** `createAttentionCompiler(options?: AttentionCompilerOptions, context?)` is the factory; `AttentionCompilerOptions` carries the gate ratios, sticky-stage tuning (`thinkingKeepTurns`, `keepLast`), `excludeTools`, and `reserveTokens`. `resolveInputCap(options?: AttentionInputCapOptions, model?)` is the cap resolver, `compileAttention(options: AttentionCompileOptions)` is the per-turn call `assembleProviderInput` makes (`AttentionCompileOptions` also carries `fold`, `frontier`, `redactor`, `signal`, and the `turn`/`sessionId`/`runId` telemetry ids), and `createAttentionTruncationTrigger(options?: AttentionTruncationTriggerOptions)` builds the host-programmable compaction trigger.
|
|
77
|
+
**Public surface.** `createAttentionCompiler(options?: AttentionCompilerOptions, context?)` is the factory; `AttentionCompilerOptions` carries the gate ratios, the optional `trigger` axes, sticky-stage tuning (`thinkingKeepTurns`, `keepLast`), `excludeTools`, and `reserveTokens`. `resolveInputCap(options?: AttentionInputCapOptions, model?)` is the cap resolver, `compileAttention(options: AttentionCompileOptions)` is the per-turn call `assembleProviderInput` makes (`AttentionCompileOptions` also carries `fold`, `frontier`, `redactor`, `signal`, `runInputTokens`, and the `turn`/`sessionId`/`runId` telemetry ids), and `createAttentionTruncationTrigger(options?: AttentionTruncationTriggerOptions)` builds the host-programmable compaction trigger. The frozen handle carries the normalized `trigger` axes, the resolved `runInputBudget`, and `durable`, so a caller can never resolve one and evaluate against another.
|
|
76
78
|
|
|
77
79
|
Input cap resolution: `maxInputTokens` when set, otherwise `contextWindow - (maxOutputTokens ?? 0) - reserveTokens`. Both `resolveInputCap(options?, model?)` and the compiler fail closed with a `TypeError` when neither source is present, when a declared limit is malformed, or when the computed cap is not positive.
|
|
78
80
|
|
|
@@ -82,10 +84,56 @@ Turn options, passed to `assembleProviderInput`:
|
|
|
82
84
|
| --- | --- | --- |
|
|
83
85
|
| `attentionCompiler` | `AttentionCompilerOptions \| AttentionCompiler` | Raw options are validated for that call; a resolved handle reuses one validation. The session passes the run's resolved handle so a tuning typo fails before the first provider turn. |
|
|
84
86
|
| `attentionSticky` | `AttentionStickyFrontier` | `{ thinking, toolCallIds }` sets from `createAttentionStickyFrontier()`. The session supplies its own; a direct `assembleProviderInput` caller owns it, and omitting it makes each call mutate for its turn only. |
|
|
87
|
+
| `runInputTokens` | `number` | Run input tokens already charged by provider usage this run (default 0), so the cumulative `run_input_ratio` axis can project this turn onto the spend. The session passes the run limit counter. |
|
|
85
88
|
| `onAttentionReport` | `(report: AttentionReport) => void` | Called once per **mutated** turn, before `input_assembly` middleware; silent under the ratio. The session uses it to emit `attention_compiled`. |
|
|
86
89
|
|
|
87
90
|
`attentionCompiler` and `contextBudget` are **mutually exclusive** — a compiler-on turn that is still over throws `AttentionBudgetError` rather than evicting through the budget, so passing both fails closed with a `TypeError`.
|
|
88
91
|
|
|
92
|
+
### Trigger axes
|
|
93
|
+
|
|
94
|
+
`trigger` replaces `triggerRatio` as the gate (plan 086 T2). It takes one axis, one predicate function, or an array of them; an array is **any-of**, and the first axis that fires is the one attributed on the report.
|
|
95
|
+
|
|
96
|
+
| Kind | Fires when | Folds to | Fails closed? |
|
|
97
|
+
| --- | --- | --- | --- |
|
|
98
|
+
| `{ kind: "input_ratio", ratio }` | the assembled request reaches `ratio × inputCap` — the legacy `triggerRatio` axis | `ratio × inputCap` | yes |
|
|
99
|
+
| `{ kind: "run_input_ratio", ratio }` | `runInputTokens + estimatedInputTokens` reaches `ratio × runInputBudget`, so a run capped below the model window folds before the cap kills it | every eligible row (a cumulative gate has no per-request target) | **no** — the spend is already booked; folding only slows the counter, and the run limit owns the cap |
|
|
100
|
+
| `{ kind: "token_floor", tokens }` | the assembled request reaches `tokens` tokens, whatever the cap | `tokens` | yes |
|
|
101
|
+
| `{ kind: "predicate", shouldFold }` (or a bare function) | `shouldFold(state)` returns `true` | every eligible row | yes |
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// The synapta Plan 118 shape: a 1M-window model under a 500k run input cap, where the legacy
|
|
105
|
+
// 0.75 × window gate (745k) could never open before the run died at its cap.
|
|
106
|
+
const compiler = createAttentionCompiler(
|
|
107
|
+
{ trigger: { kind: "run_input_ratio", ratio: 0.75 }, keepLast: 3 },
|
|
108
|
+
{ model, runInputBudget: 500_000 },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Any-of, attributed in order: the floor is reported when both would fire.
|
|
112
|
+
createAttentionCompiler({ trigger: [{ kind: "token_floor", tokens: 120_000 }, { kind: "input_ratio", ratio: 0.9 }] }, { model });
|
|
113
|
+
|
|
114
|
+
// Host predicate: synchronous, evaluated at most twice per turn (once to decide, once to
|
|
115
|
+
// confirm the stages settled it) with a frozen `AttentionTriggerState`.
|
|
116
|
+
createAttentionCompiler(
|
|
117
|
+
{ trigger: (state) => state.estimatedInputTokens > 150_000 && state.turn > 5 },
|
|
118
|
+
{ model },
|
|
119
|
+
);
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`AttentionTriggerState` is frozen and carries estimates only: `estimatedInputTokens` (this turn's assembled request), `inputCapTokens`, `runInputBudgetTokens` (absent when the run declares none), `runInputTokens` (charged spend so far), and `turn`. A predicate that returns a non-boolean — including a `Promise` from an `async` function — fails closed with a `TypeError` naming the option, rather than silently never firing.
|
|
123
|
+
|
|
124
|
+
Predicate axes run **host-supplied code**, under the same trust as `CompactionTrigger.custom`: the compiler passes host data in and takes a boolean out, never credentials or payloads. Keep them synchronous and side-effect free; they see token estimates and ids, never message text.
|
|
125
|
+
|
|
126
|
+
Rules that hold for every axis:
|
|
127
|
+
|
|
128
|
+
- **Config-time validation.** Unknown kinds, a ratio outside `(0, 1)`, a non-positive `token_floor.tokens`, an empty array, and a predicate that is not a function all throw a `TypeError` naming the option (`attentionCompiler.trigger`, or `attentionCompiler.trigger[1]` inside an array).
|
|
129
|
+
- **Gate is agent-config only.** A run overlay may not set `trigger`, `maxInputTokens`, or `reserveTokens` — the gate and the cap move together, so moving either belongs in the agent config.
|
|
130
|
+
- **One evaluation per turn.** The axes are evaluated at turn start against the measured request and once more after the stages. The per-row loop then compares numbers, so a predicate costs two calls a turn no matter how many rows are eligible.
|
|
131
|
+
- **Sticky and monotonic as ever.** An axis only decides *whether* to fold; the stages still stop as soon as a target is reached, and mutations stay applied on later under-gate turns.
|
|
132
|
+
- **`run_input_ratio` needs its budget.** With `compactAfterTokens`-style run limits declared (`RunLimits.maxInputTokens`, which defaults to `40_000` and kills the run cumulatively), pass the resolved value as `runInputBudget`. Without one the axis is the per-request `input_ratio` comparison.
|
|
133
|
+
- **`triggerRatio` stays the reference.** Omitted alongside `trigger`, it takes the first `input_ratio` axis's ratio so `compactRatio` and the report still describe the real fold point; with no `input_ratio` axis it keeps the default `0.75` as the compaction reference.
|
|
134
|
+
|
|
135
|
+
Runnable end to end: [`examples/attention-budget-axes.ts`](../examples/attention-budget-axes.ts) runs the scenario both axes exist for — a 1M window, a 500k run budget, 24 provider turns. The window axis would need 743k tokens and never gets near (`maxUsed` ≈ 7k, so it never fires); cumulative spend crosses 1 % of the budget on turn 6, the gate opens there, and the run finishes having spent 36k of its 500k with the newest 2 rows raw and every older body a stub. `src/__tests__/attention-compiler-budget.test.ts` asserts the same numbers, including that the first fold could only be explained by carried-over spend.
|
|
136
|
+
|
|
89
137
|
## Outputs / response / events
|
|
90
138
|
|
|
91
139
|
`createAttentionCompiler` returns an `AttentionCompiler`: `inputCap`, `reserveTokens`, `triggerRatio`, `compactRatio`, `thinkingKeepTurns`, `keepLast`, and a frozen, de-duplicated `excludeTools`. It performs no I/O and calls no provider.
|
|
@@ -97,10 +145,12 @@ Turn options, passed to `assembleProviderInput`:
|
|
|
97
145
|
| `used` | `number` | Estimated tokens measured before this turn's mutation. |
|
|
98
146
|
| `usedAfter` | `number` | Estimated tokens of the same request after the mutation, so `used` → `usedAfter` is the per-turn cost curve. |
|
|
99
147
|
| `inputCap` | `number` | Resolved cap the ratio was compared against. |
|
|
100
|
-
| `triggerRatio` | `number` | Configured ratio. |
|
|
148
|
+
| `triggerRatio` | `number` | Configured ratio — the reference axis, whether or not a `trigger` replaced the gate. |
|
|
149
|
+
| `firedAxis` | `AttentionTriggerKind?` | Axis that opened the gate on this turn, in configured order; absent on an under-gate turn. Plan 087 attribution reads this. |
|
|
101
150
|
| `droppedThinkingTurns` | `number` | Thinking turns absent from this request — rows re-applied from the sticky frontier count again. |
|
|
102
151
|
| `stubbedToolResults` | `number` | Tool results stubbed in this request — re-applied rows count again. |
|
|
103
152
|
| `stubbedBytes` | `number` | Payload bytes those stubs took out of the request (message bytes minus the stub header). |
|
|
153
|
+
| `newFoldedBodies` | `number` | Folded bodies this turn added to the ledger: the `summarize` calls the cache saved, and the durable-fold checkpoint signal. `0` on a turn that only re-applied stored bodies. |
|
|
104
154
|
| `truncated` | `boolean` | `true` when the gate stopped with eligible rows left, so the sticky frontier is partial. |
|
|
105
155
|
| `runId` / `sessionId` | `string?` | Owning run/session when known. |
|
|
106
156
|
|
|
@@ -119,7 +169,7 @@ Tool result read_file [call_1]: omitted 41_982 bytes (sha256 3f9a1c2b4d5e6f70a1b
|
|
|
119
169
|
|
|
120
170
|
Never stubbed: rows named in `excludeTools`, tool **errors**, results stamped as a decision/approval payload (`approval`, `approvalId`, `prismApproval`, `decision`, `decisions`, `pendingDecisions`, `elicitation` metadata), rows the host fold's own age/byte gates exclude, and any row whose stub would cost more than the payload it replaces. When `toolResultFold.summarize` is configured, that function produces the stub body for the rows the compiler picked (capped by its `maxSummaryBytes`); otherwise the deterministic digest above is used.
|
|
121
171
|
|
|
122
|
-
`compileAttention({ compiler, groups, context?, skills?, tools?, fold?, frontier?, redactor?, signal?, turn?, sessionId?, runId? })` is what `assembleProviderInput` calls; it returns `{ groups, mutated, report }`. Under the ratio it returns the **same groups object** it was given; when it mutates it returns new `history` / `toolResults` arrays and never writes into the caller's arrays.
|
|
172
|
+
`compileAttention({ compiler, groups, context?, skills?, tools?, fold?, frontier?, attentionFold?, redactor?, signal?, turn?, runInputTokens?, sessionId?, runId? })` is what `assembleProviderInput` calls; it returns `{ groups, mutated, report }`. Under the ratio it returns the **same groups object** it was given; when it mutates it returns new `history` / `toolResults` arrays and never writes into the caller's arrays.
|
|
123
173
|
|
|
124
174
|
Errors:
|
|
125
175
|
|
|
@@ -137,6 +187,7 @@ Errors:
|
|
|
137
187
|
"keepLast": 3,
|
|
138
188
|
"excludeTools": ["submit_payment"],
|
|
139
189
|
"reserveTokens": 1024,
|
|
190
|
+
"durable": false,
|
|
140
191
|
"compaction": {
|
|
141
192
|
"trigger": {
|
|
142
193
|
"type": "custom",
|
|
@@ -244,11 +295,40 @@ See [Compaction and retry policies](compaction-and-retry.md) for the trigger uni
|
|
|
244
295
|
- `excludeTools` is fail closed: entries are validated as non-empty bounded strings, de-duplicated, and frozen; a named tool is never stubbed even when the request stays over the ratio.
|
|
245
296
|
- The compiler never orchestrates other levers: `toolResultFold.summarize` still wins for fold-eligible rows when a host supplies it, `applyContextBudget` keeps working unchanged for compiler-off agents, and compaction stays a task-boundary operation (`session.compact()` still throws while a run is in flight).
|
|
246
297
|
- Sticky means sticky: a stripped thinking turn is never restored and a stubbed call id is never un-stubbed, even on a later under-ratio turn — restoring either would rewrite the cached prefix. Pass no `attentionSticky` for one-shot assemblies.
|
|
247
|
-
- The frontier is bounded (256 thinking keys, 256 tool-call ids, newest kept) and lives on the session, so it survives turns and runs. A durable run with `persistSessionState: true` also writes it into the checkpoint (`sessionState.attentionSticky`) and restores it on resume, so a resumed run keeps its stubs instead of re-deciding its first turn from the ratio; a malformed or hand-edited frontier is dropped entry by entry, never fatal to a resume.
|
|
298
|
+
- The frontier is bounded (256 thinking keys, 256 tool-call ids, newest kept) and lives on the session, so it survives turns and runs. A durable run with `persistSessionState: true`, or any run with `durable: true`, also writes it into the checkpoint (`sessionState.attentionSticky`) and restores it on resume, so a resumed run keeps its stubs instead of re-deciding its first turn from the ratio; a malformed or hand-edited frontier is dropped entry by entry, never fatal to a resume.
|
|
248
299
|
- A compiler-on turn assembles from the default message groups (instructions, summaries, history, input, attachments, tool results) exactly like a `contextBudget` turn, so a custom `inputBuilder` is not consulted while the compiler is on.
|
|
249
300
|
- Compaction timing is programmable per agent through `CompactionOptions.trigger` (`threshold_entries` | `input_ratio` | `custom`); omitting it keeps today's `thresholdEntries` gate. `assertCompactionTrigger(trigger)` validates a trigger independently of the compiler.
|
|
250
301
|
- The gate is opt-in per agent/run; omit the option for current assembly bytes.
|
|
251
302
|
|
|
303
|
+
### Durable folding
|
|
304
|
+
|
|
305
|
+
`durable: true` puts the fold state on disk (plan 086 T3), so a run that dies mid-investigation resumes
|
|
306
|
+
with the rows it had already folded instead of re-deciding them from the ratio.
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
const agent = createAgent({
|
|
310
|
+
// ...
|
|
311
|
+
attentionCompiler: {
|
|
312
|
+
trigger: { kind: "run_input_ratio", ratio: 0.75 },
|
|
313
|
+
keepLast: 2,
|
|
314
|
+
durable: true,
|
|
315
|
+
},
|
|
316
|
+
runState: { checkpoints, definitionRevision: "1" }, // the write target; `persistSessionState` not required
|
|
317
|
+
toolResultFold: { summarize: hostSummarize }, // optional: bodies become durable too
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// After a crash the worker resumes where the fold left off:
|
|
321
|
+
await resumeAgentRun(agent, { runId, sessionId }, { decision: "continue", expectedVersion }, { checkpoints, definitionRevision: "1" });
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
- **One write per fold, never per turn.** The checkpoint is written after the turn's request is assembled and before the provider sees it, only on turns that added folded bodies. A turn that re-applies what the ledger already holds writes nothing.
|
|
325
|
+
- **The fold ledger.** Each folded body is stored once, keyed by tool call id (newest 64, 4 KiB each), and re-applied on every later turn: the host `summarize` runs once per row instead of once per turn, and a sticky row stays byte-identical for the provider cache. Bodies are already redacted and capped by the fold that produced them.
|
|
326
|
+
- **Independent of `persistSessionState`.** That option governs skill and tool-activation state. `durable` is its own opt-in for the fold ledger plus its sticky frontier (`sessionState.attentionFold` / `attentionSticky`), because a resumed run needs both: the frontier decides *what* stays folded, the ledger decides *what body* it was folded to.
|
|
327
|
+
- **Restore is fault-tolerant.** A malformed ledger shape starts from an empty ledger, and a malformed entry is dropped one by one — the row simply re-folds on the next over-gate turn. A hand-edited checkpoint never blocks a resume.
|
|
328
|
+
- **Sizing.** Off by default. On, it costs one checkpoint write per fold turn plus `bodies × (body ≤ maxSummaryBytes)` bytes in the run state (default cap: 64 bodies), and it makes the fold the run's first crash-recovery point when `checkpointPolicy` is `"decision"`.
|
|
329
|
+
- **Requires a durable run.** `durable: true` without `runState` (a checkpoint store) throws `AgentRunStateError` at run start, before the first provider turn. A run overlay may not set `durable`.
|
|
330
|
+
- **Still projection-only.** Durability changes *where the projection is remembered*, not what the store holds: the session store, observational-memory ledger, and semantic stores keep every original payload for recall, branching, and audit.
|
|
331
|
+
|
|
252
332
|
## Security and performance notes
|
|
253
333
|
|
|
254
334
|
- Validation is synchronous with no provider I/O, and the returned handle plus `excludeTools` are frozen.
|
|
@@ -264,9 +344,10 @@ See [Compaction and retry policies](compaction-and-retry.md) for the trigger uni
|
|
|
264
344
|
|
|
265
345
|
- [`assembleProviderInput`](input-and-prompt-assembly.md): the compose path the compiler pre-passes when enabled.
|
|
266
346
|
- [`toolResultFold`](input-and-prompt-assembly.md): host summarizer that wins over the deterministic stub for eligible rows.
|
|
267
|
-
- [`CompactionOptions`](compaction-and-retry.md): `trigger` is the host compact-when seam; `thresholdEntries` remains the default gate.
|
|
347
|
+
- [`CompactionOptions`](compaction-and-retry.md): `trigger` is the host compact-when seam; `thresholdEntries` remains the default gate. For fold state that outlives a crash, see [Durable folding](#durable-folding).
|
|
268
348
|
- [`observational-memory`](compaction-observational-memory.md): host `shouldCompact` / trigger overrides `compactAfterTokens` for post-run compaction.
|
|
269
349
|
- [`provider caching`](provider-caching.md): why mutations are monotonic and in-place.
|
|
270
350
|
- [`AttentionReport` measurements](_evidence/phase74-attention-measurements.md): the hermetic fixture behind the savings, cache, resume, and truncation numbers.
|
|
351
|
+
- Example: [`examples/attention-budget-axes.ts`](../examples/attention-budget-axes.ts) — budget-capped long run where only the cumulative axis can open the gate.
|
|
271
352
|
- [Memory fabric](memory-fabric.md): a context source whose blocks are measured like any other (`working-memory` / `semantic-memory` tags, no layer id).
|
|
272
353
|
- [`thinking and reasoning`](thinking-and-reasoning.md): the `thinking` blocks the first stage strips.
|
|
@@ -610,7 +610,7 @@ Every configurable value is a positive safe integer (context may be zero); Prism
|
|
|
610
610
|
- [Language intelligence](language-intelligence.md): optional host-activated LSP contract (`createLanguageIntelligence`) — symbols/definitions/references/diagnostics/hover/rename.
|
|
611
611
|
- [Process sessions](process-sessions.md): optional managed long-running processes (`createProcessSessions`) — start/output/input/wait/signal/kill/release.
|
|
612
612
|
- [Forge integration](forge-integration.md): optional GitHub adapter (`createGitHubForge`) — issue context, authenticated push, PR create/update, review comments, checks, bounded handoff reconcile; effect-store idempotency, no duplicate PRs/comments on retry, tokens never in argv/logs/events.
|
|
613
|
-
- [Tools](tools.md): the host-owned tool harness — `createToolRegistry`, `dispatchToolCall`, filtering, and the `ToolDefinition` contract these factories satisfy.
|
|
613
|
+
- [Tools](tools.md): the host-owned tool harness — `createToolRegistry`, `dispatchToolCall`, filtering, `toolNarrowing` per-turn menus, and the `ToolDefinition` contract these factories satisfy.
|
|
614
614
|
- [Public contracts](public-contracts.md): `ToolDefinition`, `ToolResult`, `ToolExecutionContext`, `ContentBlock`, and `JsonObject` shapes.
|
|
615
615
|
- [Host security guide](host-security.md): fail-closed checklist for permission policies, tool validation, and trust boundaries that must gate these tools.
|
|
616
616
|
- [Tool conformance](tool-conformance.md): assertions for the tool-dispatch blocked-reason matrix these tools participate in.
|
|
@@ -203,7 +203,7 @@ The default strategy does not call a provider. Hosts that need model-generated s
|
|
|
203
203
|
- [Session stores and branching](session-stores-and-branching.md): branch entries, compaction entries, and `rebuildSessionContext()` behavior.
|
|
204
204
|
- [Input and prompt assembly](input-and-prompt-assembly.md): compacted summaries become default summary messages for provider input.
|
|
205
205
|
- [Agent/session runtime](agent-session-runtime.md): `session.compact()`, opt-in auto-compaction, `RunOptions.retry`, and `retry_scheduled` runtime behavior.
|
|
206
|
-
- [Attention compiler](attention-compiler.md): resolves the same input cap and shrinks an over-ratio request before compaction is considered.
|
|
206
|
+
- [Attention compiler](attention-compiler.md): resolves the same input cap and shrinks an over-ratio request before compaction is considered; with `attention.compiler.durable` the fold ledger and frontier are checkpointed per fold, so a run that dies mid-investigation resumes already folded instead of replaying the pre-fold tail.
|
|
207
207
|
- Example: [`examples/autonomous-coding-loop.ts`](../examples/autonomous-coding-loop.ts) — task-boundary compact after each iteration.
|
|
208
208
|
- [Middleware hooks](middleware-hooks.md): `compaction` and `retry` middleware payload timing.
|
|
209
209
|
- [Contribution registries](contribution-registries.md): compaction strategy and retry policy contributions.
|