@arnilo/prism 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/README.md +13 -12
  3. package/dist/agent-approval.d.ts +17 -2
  4. package/dist/agent-approval.js +15 -6
  5. package/dist/agent-event-source.d.ts +9 -1
  6. package/dist/agent-event-source.js +10 -3
  7. package/dist/agent-loops.js +7 -4
  8. package/dist/agent-run-lifecycle.d.ts +15 -1
  9. package/dist/agent-run-lifecycle.js +82 -11
  10. package/dist/agent-run-state.d.ts +47 -6
  11. package/dist/agent-run-state.js +154 -6
  12. package/dist/agent-session/event-subscriber.d.ts +2 -0
  13. package/dist/agent-session/event-subscriber.js +3 -0
  14. package/dist/agent-session/helpers.js +14 -0
  15. package/dist/agent-session/session/assemble.js +281 -32
  16. package/dist/agent-session/session/persist.d.ts +11 -0
  17. package/dist/agent-session/session/persist.js +48 -16
  18. package/dist/agent-session/session/provider-round.d.ts +14 -4
  19. package/dist/agent-session/session/provider-round.js +226 -19
  20. package/dist/agent-session/session/tool-round.d.ts +2 -2
  21. package/dist/agent-session/session/tool-round.js +78 -6
  22. package/dist/agent-session/session/types.d.ts +44 -3
  23. package/dist/agent-session/session.d.ts +100 -5
  24. package/dist/agent-session/session.js +224 -13
  25. package/dist/attention-compiler.d.ts +51 -2
  26. package/dist/attention-compiler.js +282 -21
  27. package/dist/cache-helpers.d.ts +4 -2
  28. package/dist/cache-helpers.js +8 -6
  29. package/dist/checkpoint-restore.d.ts +45 -0
  30. package/dist/checkpoint-restore.js +54 -0
  31. package/dist/context-budget.d.ts +13 -1
  32. package/dist/context-budget.js +57 -4
  33. package/dist/contracts-core/agent.d.ts +52 -1
  34. package/dist/contracts-core/attention.d.ts +95 -0
  35. package/dist/contracts-core/content.d.ts +10 -0
  36. package/dist/contracts-core/extensions.d.ts +3 -0
  37. package/dist/contracts-core/guardrail-packs.d.ts +46 -0
  38. package/dist/contracts-core/guardrail-packs.js +2 -0
  39. package/dist/contracts-core/loop.d.ts +36 -0
  40. package/dist/contracts-core/provider.d.ts +30 -0
  41. package/dist/contracts-core/run-limits.d.ts +29 -1
  42. package/dist/contracts-core/session.d.ts +23 -5
  43. package/dist/contracts-core/session.js +21 -2
  44. package/dist/contracts-core/usage.d.ts +40 -0
  45. package/dist/contracts-core/usage.js +8 -0
  46. package/dist/contracts-core.d.ts +2 -0
  47. package/dist/contracts-core.js +2 -0
  48. package/dist/contracts-protocol.d.ts +81 -5
  49. package/dist/contracts-run-state.d.ts +91 -2
  50. package/dist/contributions.d.ts +2 -1
  51. package/dist/contributions.js +1 -0
  52. package/dist/extensions.d.ts +15 -1
  53. package/dist/extensions.js +68 -0
  54. package/dist/guardrail-packs/coding-standard.d.ts +3 -0
  55. package/dist/guardrail-packs/coding-standard.js +63 -0
  56. package/dist/guardrail-packs/destructive-commands.d.ts +3 -0
  57. package/dist/guardrail-packs/destructive-commands.js +46 -0
  58. package/dist/guardrail-packs/errors.d.ts +7 -0
  59. package/dist/guardrail-packs/errors.js +9 -0
  60. package/dist/guardrail-packs/index.d.ts +4 -0
  61. package/dist/guardrail-packs/index.js +15 -0
  62. package/dist/guardrail-packs/secrets-hygiene.d.ts +3 -0
  63. package/dist/guardrail-packs/secrets-hygiene.js +23 -0
  64. package/dist/guardrail-packs/types.d.ts +26 -0
  65. package/dist/guardrail-packs/types.js +2 -0
  66. package/dist/guardrail-packs/validation-respect.d.ts +3 -0
  67. package/dist/guardrail-packs/validation-respect.js +69 -0
  68. package/dist/guardrails.d.ts +61 -1
  69. package/dist/guardrails.js +377 -0
  70. package/dist/index.d.ts +16 -11
  71. package/dist/index.js +10 -7
  72. package/dist/input.d.ts +8 -1
  73. package/dist/input.js +68 -6
  74. package/dist/middleware.d.ts +37 -2
  75. package/dist/middleware.js +41 -0
  76. package/dist/node/session-store-jsonl.js +18 -3
  77. package/dist/observability.js +6 -0
  78. package/dist/provider-events.d.ts +8 -2
  79. package/dist/provider-events.js +60 -2
  80. package/dist/providers/openai-compatible.js +6 -3
  81. package/dist/run-bundle.d.ts +6 -1
  82. package/dist/run-bundle.js +5 -1
  83. package/dist/run-limits.d.ts +11 -1
  84. package/dist/run-limits.js +59 -0
  85. package/dist/session-stores.d.ts +12 -1
  86. package/dist/session-stores.js +21 -4
  87. package/dist/testing/agent-event-source-conformance.js +41 -2
  88. package/dist/testing/prefix-stability-conformance.d.ts +59 -0
  89. package/dist/testing/prefix-stability-conformance.js +172 -0
  90. package/dist/testing/session-store-conformance.d.ts +3 -2
  91. package/dist/testing/session-store-conformance.js +48 -0
  92. package/dist/tools.d.ts +5 -0
  93. package/dist/tools.js +21 -6
  94. package/dist/usage-estimation.d.ts +29 -0
  95. package/dist/usage-estimation.js +79 -0
  96. package/docs/agent-events.md +75 -4
  97. package/docs/agent-session-runtime.md +10 -6
  98. package/docs/attention-compiler.md +89 -8
  99. package/docs/caveman.md +1 -1
  100. package/docs/coding-agent-tools.md +1 -1
  101. package/docs/compaction-and-retry.md +1 -1
  102. package/docs/compaction-llm.md +2 -0
  103. package/docs/compaction-observational-memory.md +54 -7
  104. package/docs/durable-runs.md +46 -3
  105. package/docs/embeddings.md +9 -0
  106. package/docs/evaluations.md +5 -0
  107. package/docs/execution-timeline.md +79 -1
  108. package/docs/extensions.md +20 -3
  109. package/docs/guardrails.md +50 -4
  110. package/docs/hooks.md +282 -0
  111. package/docs/index.md +37 -15
  112. package/docs/input-and-prompt-assembly.md +4 -4
  113. package/docs/instruction-injection.md +1 -0
  114. package/docs/knowledge-sync.md +4 -0
  115. package/docs/live-testing.md +3 -1
  116. package/docs/memory-fabric.md +28 -0
  117. package/docs/middleware-hooks.md +90 -4
  118. package/docs/migrate-to-0.9.md +210 -0
  119. package/docs/migration.md +26 -0
  120. package/docs/multi-agent-patterns.md +25 -2
  121. package/docs/node-jsonl-session-store.md +7 -1
  122. package/docs/observability.md +7 -3
  123. package/docs/options-index.md +4 -1
  124. package/docs/policy-and-audit.md +26 -1
  125. package/docs/prefix-stability-conformance.md +143 -0
  126. package/docs/provider-caching.md +4 -4
  127. package/docs/provider-conformance.md +16 -0
  128. package/docs/provider-packages.md +20 -20
  129. package/docs/public-contracts.md +3 -2
  130. package/docs/rag.md +188 -3
  131. package/docs/release-and-install.md +45 -40
  132. package/docs/runs-and-usage.md +56 -10
  133. package/docs/scoped-agent-memory.md +270 -0
  134. package/docs/scoped-memory.md +138 -0
  135. package/docs/session-store-conformance.md +1 -2
  136. package/docs/session-stores.md +17 -17
  137. package/docs/supervisors.md +32 -12
  138. package/docs/tools.md +18 -1
  139. package/docs/wiki.md +4 -2
  140. package/docs/workflows.md +5 -0
  141. package/package.json +8 -2
@@ -28,14 +28,53 @@ export async function assertAgentEventSourceConforms(factory) {
28
28
  const final = await source.page({ ...input, after: secondPage.items[0].cursor, limit: 10 });
29
29
  equal(final.items.at(-1)?.record.id, terminal.id, "terminal page must include its terminal event");
30
30
  equal(final.terminal, true, "terminal event must close only after prior events are delivered");
31
+ // A limit death delivers its attribution before the run's outcome: the breach and budget records
32
+ // are not terminal, the `error` that follows them is.
33
+ const breached = { ...input, runId: "run-limit" };
34
+ await source.append(event("event-limit-before", "turn_started", breached, "2026-01-01T00:00:04.000Z"));
35
+ const beforeLimitItem = (await source.page({ ...breached, limit: 1 })).items[0];
36
+ if (!beforeLimitItem)
37
+ throw new Error("limit-death precondition missing");
38
+ const beforeLimit = beforeLimitItem.cursor;
39
+ await source.append(event("event-limit", "run_limit_exceeded", breached, "2026-01-01T00:00:05.000Z"));
40
+ await source.append(event("event-attr", "budget_exhausted", breached, "2026-01-01T00:00:06.000Z"));
41
+ const breachPage = await source.page({ ...breached, after: beforeLimit, limit: 10 });
42
+ equal(breachPage.items.length, 2, "the breach and its attribution must page together");
43
+ equal(breachPage.terminal, false, "a limit breach must not close the stream; the run's error follows");
44
+ await source.append(event("event-limit-error", "error", breached, "2026-01-01T00:00:07.000Z"));
45
+ const deadPage = await source.page({ ...breached, after: beforeLimit, limit: 10 });
46
+ equal(deadPage.terminal, true, "the error after a limit breach closes the stream");
47
+ const death = source.subscribe(breached)[Symbol.asyncIterator]();
48
+ const delivered = [];
49
+ for (;;) {
50
+ const next = await death.next();
51
+ if (next.done)
52
+ break;
53
+ delivered.push(next.value.record.type);
54
+ }
55
+ equal(delivered.join(","), "turn_started,run_limit_exceeded,budget_exhausted,error", "a subscriber must read the breach and its attribution before the stream ends");
31
56
  await rejects(() => source.page({ ...input, ownership: { ...ownership, tenantId: "tenant-b" }, after: page.nextCursor }), "foreign cursor must fail closed");
32
57
  await rejects(() => source.append({ ...event("event-unredacted", "turn_started", input), redacted: false }), "unredacted append must fail");
33
58
  await rejects(() => source.page({ ...input, limit: 0 }), "invalid page limit must fail");
34
59
  }
35
60
  function event(id, type, input, timestamp = "2026-01-01T00:00:00.000Z") {
61
+ const scoped = { sessionId: input.sessionId, runId: input.runId };
36
62
  const event = type === "turn_started"
37
- ? { type, sessionId: input.sessionId, runId: input.runId, turn: 1 }
38
- : { type, sessionId: input.sessionId, runId: input.runId };
63
+ ? { type, ...scoped, turn: 1 }
64
+ : type === "run_limit_exceeded"
65
+ ? { type, ...scoped, breach: { limit: "maxTurns", maximum: 1, observed: 2 } }
66
+ : type === "budget_exhausted"
67
+ ? {
68
+ type,
69
+ ...scoped,
70
+ limit: "maxTurns",
71
+ consumed: { turns: 2, inputTokens: 0, providerAttempts: 1, requestBytes: 0 },
72
+ closestOtherAxes: [],
73
+ recentToolCalls: [],
74
+ }
75
+ : type === "error"
76
+ ? { type, ...scoped, error: { message: "run limit exceeded" } }
77
+ : { type, ...scoped };
39
78
  return { id, ...input.ownership, sessionId: input.sessionId, runId: input.runId, type, timestamp, event, redacted: true };
40
79
  }
41
80
  function equal(actual, expected, message) {
@@ -0,0 +1,59 @@
1
+ import type { AgentConfig, Skill } from "../contracts.js";
2
+ export interface PrefixStabilityConformanceOptions {
3
+ /**
4
+ * The host's own agent config, minus `provider`, `providerSource`, and `skills`:
5
+ * the runner installs its fixture provider and fixture skill registry so the
6
+ * scenario is deterministic and comparable across hosts.
7
+ */
8
+ readonly host: Omit<AgentConfig, "provider" | "providerSource" | "skills">;
9
+ /** Two distinct skills: the fixture loads `[0]` on the first turn and `[1]` on the second. */
10
+ readonly skills: readonly [Skill, Skill];
11
+ /** Minimum shared byte-prefix fraction between consecutive requests. Default `0.95`. */
12
+ readonly minContinuity?: number;
13
+ /**
14
+ * Which fraction gates the run: `"providerPrefix"` (default, today's behavior) asserts the
15
+ * provider-visible prefix; `"cacheablePrefix"` asserts the same measurement with tail segments
16
+ * removed, so a body-heavy or eager host is not failed for the tail it deliberately re-sends.
17
+ */
18
+ readonly assertOn?: "providerPrefix" | "cacheablePrefix";
19
+ /** Turn inputs; defaults are fixed strings so runs are comparable across hosts. */
20
+ readonly inputs?: readonly [string, string];
21
+ /**
22
+ * How many request pairs may break below `minContinuity` (default `0`, today's behavior). Use
23
+ * `1` for an assembly that folds or evicts exactly one boundary — an attention-compiler fold,
24
+ * a compaction, a budget eviction. More resets than declared fail, and fewer fail too: the
25
+ * fixture was supposed to invalidate the prefix, so a run that never did cannot pass vacuously.
26
+ */
27
+ readonly allowedResets?: number;
28
+ }
29
+ export interface PrefixStabilityConformanceResult {
30
+ /** Provider requests captured by the fixture (two per turn: skill load, then completion). */
31
+ readonly requests: number;
32
+ /** Lowest shared-prefix fraction observed across consecutive captured requests. */
33
+ readonly minContinuity: number;
34
+ /**
35
+ * The same lowest fraction with the session's tail segments removed from both requests of each
36
+ * pair — the provider-visible prefix the cache can actually keep paying for. Equals
37
+ * `minContinuity` when no captured request carried a tail segment.
38
+ */
39
+ readonly cacheableContinuity: number;
40
+ /**
41
+ * 1-based indexes of the captured requests whose asserted prefix broke below `minContinuity`
42
+ * (the later request of each pair), in ascending order — where the assembly invalidated the
43
+ * prefix instead of appending. Empty when every gap stayed above the minimum.
44
+ */
45
+ readonly resets: readonly number[];
46
+ }
47
+ /**
48
+ * Drive a real session through two staggered skill loads and assert that each
49
+ * provider request keeps a byte-identical leading prefix (messages **and** tool
50
+ * schemas) with its predecessor. Progressive disclosure appends a loaded body
51
+ * after the stable prefix, so the shared prefix stays intact; a host that
52
+ * rewrites the context block, the skill catalog, or any leading message per
53
+ * request fails with the offending request pair and the measured fraction.
54
+ * Reports both the provider-visible fraction and the same fraction with the
55
+ * session's tail segments removed; `assertOn` picks which one gates the run.
56
+ * A gap below the minimum is collected as a reset instead of failing in the
57
+ * loop, so `allowedResets` can permit the one boundary an assembly folds at.
58
+ */
59
+ export declare function runPrefixStabilityConformance(options: PrefixStabilityConformanceOptions): Promise<PrefixStabilityConformanceResult>;
@@ -0,0 +1,172 @@
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, providerThinkingDelta, 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
+ * Reports both the provider-visible fraction and the same fraction with the
19
+ * session's tail segments removed; `assertOn` picks which one gates the run.
20
+ * A gap below the minimum is collected as a reset instead of failing in the
21
+ * loop, so `allowedResets` can permit the one boundary an assembly folds at.
22
+ */
23
+ export async function runPrefixStabilityConformance(options) {
24
+ const { host, skills } = options;
25
+ const minContinuity = options.minContinuity ?? 0.95;
26
+ const [first, second] = skills;
27
+ assert.notEqual(first.name, second.name, "prefix stability conformance needs two distinct skills");
28
+ const bodies = [];
29
+ for (const skill of skills) {
30
+ const instructions = skill.instructions;
31
+ assert.ok(typeof instructions === "string" && instructions.length > 0, `prefix stability conformance skill ${skill.name} needs non-empty instructions`);
32
+ bodies.push(instructions);
33
+ }
34
+ const requests = [];
35
+ const registry = createSkillRegistry([...skills]);
36
+ const hostTools = host.tools && "list" in host.tools ? host.tools.list() : (host.tools ?? []);
37
+ const agent = createAgent({
38
+ ...host,
39
+ skills: registry,
40
+ tools: [...hostTools, createLoadSkillTool({ registry })],
41
+ provider: fixtureProvider(requests, [first.name, second.name], host.attentionCompiler === true || typeof host.attentionCompiler === "object"),
42
+ });
43
+ const session = agent.createSession();
44
+ const [firstInput, secondInput] = options.inputs ?? ["Prefix stability turn one", "Prefix stability turn two"];
45
+ const runOptions = { activeSkills: [first.name, second.name], limits: { maxToolRounds: 1 } };
46
+ await session.run(firstInput, runOptions);
47
+ await session.run(secondInput, runOptions);
48
+ assert.equal(requests.length, 4, `prefix stability conformance expected 4 provider requests (2 per staggered turn), captured ${requests.length}`);
49
+ // The session's own map holds the exact `Message` objects `appendTailSegment` allocated, so the
50
+ // classification is exact rather than a heuristic over host-authored content. (`tailSegments` is
51
+ // runtime-session state, not part of the public `AgentSession` contract, hence the narrow above.)
52
+ const isTail = tailClassifier(session.tailSegments);
53
+ const captured = requests.map((request) => measureRequest(request, isTail));
54
+ // Guard against a vacuous pass: both bodies must have been disclosed by the end.
55
+ const last = captured.at(-1)?.providerPrefix ?? "";
56
+ for (const [index, skill] of skills.entries()) {
57
+ assert.ok(last.includes(bodies[index] ?? ""), `prefix stability conformance: skill ${skill.name} body never reached the provider request — progressive disclosure did not expand it`);
58
+ }
59
+ const assertOn = options.assertOn ?? "providerPrefix";
60
+ const allowedResets = options.allowedResets ?? 0;
61
+ assert.ok(Number.isSafeInteger(allowedResets) && allowedResets >= 0, "prefix stability conformance allowedResets must be a non-negative safe integer");
62
+ let observed = 1;
63
+ let cacheableObserved = 1;
64
+ let previous = captured.at(0) ?? { providerPrefix: "", cacheablePrefix: "" };
65
+ // Collect every gap first: an allowed reset must not be hidden by a later assert, and the
66
+ // vacuity check needs the whole list to prove the fixture folded exactly as declared.
67
+ const gaps = [];
68
+ for (let index = 1; index < captured.length; index += 1) {
69
+ const next = captured[index] ?? previous;
70
+ const fraction = sharedPrefixFraction(previous.providerPrefix, next.providerPrefix);
71
+ const cacheableFraction = sharedPrefixFraction(previous.cacheablePrefix, next.cacheablePrefix);
72
+ observed = Math.min(observed, fraction);
73
+ cacheableObserved = Math.min(cacheableObserved, cacheableFraction);
74
+ const measured = assertOn === "cacheablePrefix" ? cacheableFraction : fraction;
75
+ if (measured < minContinuity)
76
+ gaps.push({ request: index + 1, fraction, cacheableFraction });
77
+ previous = next;
78
+ }
79
+ const resets = gaps.map((gap) => gap.request);
80
+ const measuredLabel = assertOn === "cacheablePrefix" ? "previous cacheable prefix (tail segments excluded)" : "previous provider prefix";
81
+ const minimum = (minContinuity * 100).toFixed(1);
82
+ const observedResets = `resets ${formatResets(resets)} of ${captured.length - 1} request pairs`;
83
+ const firstGap = gaps[0];
84
+ if (firstGap !== undefined && gaps.length > allowedResets) {
85
+ const measured = assertOn === "cacheablePrefix" ? firstGap.cacheableFraction : firstGap.fraction;
86
+ assert.fail(`prefix stability conformance: request ${firstGap.request - 1} → ${firstGap.request} kept ${(measured * 100).toFixed(1)}% of the ${measuredLabel} ` +
87
+ `(minimum ${minimum}%), and ${gaps.length} pair(s) broke below it (${observedResets}, allowedResets ${allowedResets}). ` +
88
+ "Late skill bodies must append after the stable prefix; recomposed context, an in-place skill-catalog rewrite, or any leading-message mutation invalidates it. " +
89
+ "Pass allowedResets for the fold, compaction, or eviction the assembly performs per run, or fix the assembly so every other gap stays byte-stable.");
90
+ }
91
+ if (gaps.length < allowedResets) {
92
+ assert.fail(`prefix stability conformance: allowedResets is ${allowedResets} but only ${gaps.length} pair(s) broke below the minimum (${minimum}% of the ${measuredLabel}); ${observedResets}. ` +
93
+ "The fixture was supposed to invalidate the prefix at those boundaries — drop allowedResets for an append-only assembly, or check the fold trigger or eviction condition actually fired.");
94
+ }
95
+ return { requests: captured.length, minContinuity: observed, cacheableContinuity: cacheableObserved, resets };
96
+ }
97
+ /**
98
+ * Deterministic reasoning block the fixture provider emits before each skill load when the host
99
+ * runs an attention compiler. Sized to be a real fraction of the request so the compiler's
100
+ * thinking stage (`thinkingKeepTurns`) has something to strip and the resulting fold is visible
101
+ * in the measured prefix.
102
+ */
103
+ const FIXTURE_THINKING = "Prefix-stability fixture reasoning: the harness measures a byte-shared provider prefix, so this block exists only to give the attention-compiler thinking stage deterministic content to strip. ".repeat(17);
104
+ /**
105
+ * Fixture provider: turn 1 loads `skillNames[0]`, turn 2 loads `skillNames[1]`, everything else
106
+ * completes. With `reasoning` (the host runs an attention compiler) each skill-load round also
107
+ * carries a thinking block, so the compiler's thinking stage has real content to fold.
108
+ */
109
+ function fixtureProvider(requests, skillNames, reasoning) {
110
+ let call = 0;
111
+ return {
112
+ id: "prefix-stability-fixture",
113
+ async *generate(request) {
114
+ requests.push(request);
115
+ const index = call;
116
+ call += 1;
117
+ const skillName = skillNames[index >> 1];
118
+ if (index % 2 === 0 && skillName !== undefined) {
119
+ if (reasoning)
120
+ yield providerThinkingDelta(FIXTURE_THINKING);
121
+ yield { type: "tool_call", call: toolCallContent(`prefix-stability-${index}`, "load_skill", { name: skillName }) };
122
+ return;
123
+ }
124
+ yield providerDone();
125
+ },
126
+ };
127
+ }
128
+ /**
129
+ * Classifies a captured message as a tail segment: by object identity first (the default builder
130
+ * passes the session's own `Message` objects through), then by serialized equality for builders
131
+ * that clone messages. Takes the pre-serialized fragment so each message is serialized once.
132
+ */
133
+ function tailClassifier(tailSegments) {
134
+ const identities = new Set(tailSegments.values());
135
+ const values = new Set([...identities].map((message) => JSON.stringify(message)));
136
+ return (message, fragment) => identities.has(message) || values.has(fragment);
137
+ }
138
+ /**
139
+ * Provider-visible payload only — messages plus the tool schema fields sent on the wire — measured
140
+ * twice: whole, and with tail segments dropped. One JSON fragment per message/tool so a structural
141
+ * array boundary never reads as a byte divergence: an appended message list stays an exact prefix
142
+ * of the next request.
143
+ */
144
+ function measureRequest(request, isTail) {
145
+ const toolParts = (request.tools ?? []).map((tool) => JSON.stringify({ name: tool.name, description: tool.description, parameters: tool.parameters }));
146
+ const providerParts = [...toolParts];
147
+ const cacheableParts = [...toolParts];
148
+ for (const message of request.messages) {
149
+ const fragment = JSON.stringify(message);
150
+ providerParts.push(fragment);
151
+ if (!isTail(message, fragment))
152
+ cacheableParts.push(fragment);
153
+ }
154
+ return { providerPrefix: providerParts.join("\n"), cacheablePrefix: cacheableParts.join("\n") };
155
+ }
156
+ /** Bracket form for reset lists, e.g. `[3]` or `[3, 4]`. */
157
+ function formatResets(resets) {
158
+ return `[${resets.join(", ")}]`;
159
+ }
160
+ /** Byte-shared prefix as a fraction of the previous request, so a shrink is a cache miss. */
161
+ function sharedPrefixFraction(previous, next) {
162
+ const before = Buffer.from(previous, "utf8");
163
+ const after = Buffer.from(next, "utf8");
164
+ if (before.length === 0)
165
+ return 1;
166
+ const limit = Math.min(before.length, after.length);
167
+ let shared = 0;
168
+ while (shared < limit && before[shared] === after[shared])
169
+ shared += 1;
170
+ return shared / before.length;
171
+ }
172
+ //# 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` (empty page, limit cap,
15
- * invalid limit/query rejection via `resolveSessionSearchQuery` semantics).
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,
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
@@ -1,5 +1,5 @@
1
1
  import { isJsonObject } from "./config.js";
2
- import { GuardrailError, runGuardrails } from "./guardrails.js";
2
+ import { GuardrailError, guardrailRefusalText, runGuardrails } from "./guardrails.js";
3
3
  import { assertIdentityActive, assertIdentityMatchesOwnership, ownershipFromIdentity } from "./identity.js";
4
4
  import { createId } from "./ids.js";
5
5
  import { errorToErrorInfo, redactRunLedgerRecord, redactSecrets } from "./redaction.js";
@@ -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(`RunOptions.toolNames exceeds ${HARD_RUN_TOOL_NAMES} entries`);
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(`RunOptions.toolNames entries must be non-empty strings of at most ${MAX_RUN_TOOL_NAME_CHARS} characters`);
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.
@@ -125,7 +133,7 @@ export async function dispatchToolCall(options) {
125
133
  if (inputGuards.terminal) {
126
134
  if (inputGuards.terminal.action !== "block")
127
135
  throw new GuardrailError(inputGuards.terminal);
128
- return blocked(mediatedCall, options.context, "guardrail_blocked", { message: "Tool call blocked by guardrail" }, options, startedAt);
136
+ return blocked(mediatedCall, options.context, "guardrail_blocked", { message: guardrailBlockMessage(inputGuards.terminal) }, options, startedAt);
129
137
  }
130
138
  const tool = options.registry.get(mediatedCall.name);
131
139
  const postcheck = await checkCall(mediatedCall, options, startedAt);
@@ -232,7 +240,7 @@ export async function dispatchToolCall(options) {
232
240
  throw new GuardrailError(outputGuards.terminal);
233
241
  if (effect)
234
242
  return finishUnknownEffect(effect, mediatedCall, context, options, startedAt);
235
- return blocked(mediatedCall, context, "guardrail_blocked", { message: "Tool result blocked by guardrail" }, options, startedAt);
243
+ return blocked(mediatedCall, context, "guardrail_blocked", { message: guardrailBlockMessage(outputGuards.terminal) }, options, startedAt);
236
244
  }
237
245
  if (effect && mediatedResult.error)
238
246
  return finishUnknownEffect(effect, mediatedCall, context, options, startedAt);
@@ -442,6 +450,13 @@ function isSuspended(error) {
442
450
  function isLoopStateError(error) {
443
451
  return typeof error?.code === "string" && error.code.startsWith("ERR_PRISM_LOOP_");
444
452
  }
453
+ /**
454
+ * Plan 104 T3/T4: the model-visible refusal line for a terminal guardrail decision. `guardrailRefusalText`
455
+ * names a compiled pack rule (bounded, redacted); any other guardrail keeps the neutral stage text.
456
+ */
457
+ function guardrailBlockMessage(record) {
458
+ return (guardrailRefusalText(record) ?? (record.stage === "tool_output" ? "Tool result blocked by guardrail" : "Tool call blocked by guardrail"));
459
+ }
445
460
  function isDelegationSuspended(error) {
446
461
  return error?.code === "ERR_PRISM_DELEGATION_SUSPENDED";
447
462
  }
@@ -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