@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
package/dist/middleware.js
CHANGED
|
@@ -1,4 +1,45 @@
|
|
|
1
1
|
import { errorToErrorInfo } from "./redaction.js";
|
|
2
|
+
/** Host answered with a malformed deterministic turn. Fails the run closed — never falls through. */
|
|
3
|
+
export class DeterministicTurnError extends Error {
|
|
4
|
+
code = "ERR_PRISM_DETERMINISTIC_TURN";
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "DeterministicTurnError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Provenance ids stay ids: bounded, no whitespace or separators outside the id alphabet. */
|
|
11
|
+
const DETERMINISTIC_MIDDLEWARE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
|
12
|
+
// Assistant-visible blocks only: a deterministic answer cannot smuggle tool execution/authority.
|
|
13
|
+
const DETERMINISTIC_BLOCK_TYPES = new Set(["text", "image", "audio", "file", "document", "video", "thinking"]);
|
|
14
|
+
/**
|
|
15
|
+
* Validate a host deterministic answer at the trust boundary: non-empty assistant-visible content and
|
|
16
|
+
* mandatory bounded provenance. Throws instead of degrading to a provider call.
|
|
17
|
+
*/
|
|
18
|
+
export function validateDeterministicTurnAnswer(value) {
|
|
19
|
+
if (typeof value !== "object" || value === null)
|
|
20
|
+
throw new DeterministicTurnError("answer must be an object");
|
|
21
|
+
const answer = value;
|
|
22
|
+
if (!Array.isArray(answer.content) || answer.content.length === 0) {
|
|
23
|
+
throw new DeterministicTurnError("answer.content must be a non-empty content block array");
|
|
24
|
+
}
|
|
25
|
+
for (const block of answer.content) {
|
|
26
|
+
const type = block?.type;
|
|
27
|
+
if (typeof block !== "object" || block === null || typeof type !== "string" || !DETERMINISTIC_BLOCK_TYPES.has(type)) {
|
|
28
|
+
throw new DeterministicTurnError("answer.content blocks must be assistant-visible content blocks (text, image, audio, file, document, video, thinking)");
|
|
29
|
+
}
|
|
30
|
+
if (type === "text" && typeof block.text !== "string") {
|
|
31
|
+
throw new DeterministicTurnError("answer.content text blocks require a string text");
|
|
32
|
+
}
|
|
33
|
+
if (type === "thinking" && typeof block.text !== "string") {
|
|
34
|
+
throw new DeterministicTurnError("answer.content thinking blocks require a string text");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const middleware = answer.provenance?.middleware;
|
|
38
|
+
if (typeof middleware !== "string" || !DETERMINISTIC_MIDDLEWARE_ID.test(middleware)) {
|
|
39
|
+
throw new DeterministicTurnError("answer.provenance.middleware must be a bounded id (1-64 chars: letters, digits, . _ : -)");
|
|
40
|
+
}
|
|
41
|
+
return { content: answer.content, provenance: { middleware } };
|
|
42
|
+
}
|
|
2
43
|
function middlewareError(error, hook, secrets) {
|
|
3
44
|
return { type: "extension_error", extension: `middleware:${hook}`, error: errorToErrorInfo(error, secrets) };
|
|
4
45
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
-
import { isSessionEntryKind, SESSION_APPEND_CONFLICT_CODE, SESSION_ENTRY_SCHEMA_VERSION, SessionAppendConflictError,
|
|
3
|
+
import { isSessionEntryKind, SESSION_APPEND_CONFLICT_CODE, SESSION_ENTRY_SCHEMA_VERSION, SessionAppendConflictError, } from "../contracts.js";
|
|
4
|
+
import { searchLinearSessions } from "../session-stores.js";
|
|
4
5
|
import { isNodeErrorCode } from "./config.js";
|
|
5
6
|
export function createJsonlSessionStore(pathOrOptions) {
|
|
6
7
|
const options = typeof pathOrOptions === "string" ? { path: pathOrOptions, createDirectory: true } : pathOrOptions;
|
|
@@ -50,8 +51,22 @@ export function createJsonlSessionStore(pathOrOptions) {
|
|
|
50
51
|
async get(id) {
|
|
51
52
|
return findEntry(path, id);
|
|
52
53
|
},
|
|
53
|
-
async searchSessions() {
|
|
54
|
-
|
|
54
|
+
async searchSessions(query) {
|
|
55
|
+
// ponytail: no index - every search reads and parses the file (O(corpus) time and memory), the
|
|
56
|
+
// recommended indexed paths are the SQLite/Postgres adapters. Corrupt lines are quarantined
|
|
57
|
+
// exactly as in list()/get(), and the contract linear caps bound entries/text scanned.
|
|
58
|
+
const { entries } = await readJsonlSessionEntries(path);
|
|
59
|
+
const bySession = new Map();
|
|
60
|
+
const leafBySession = new Map();
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const sessionEntries = bySession.get(entry.sessionId);
|
|
63
|
+
if (sessionEntries)
|
|
64
|
+
sessionEntries.push(entry);
|
|
65
|
+
else
|
|
66
|
+
bySession.set(entry.sessionId, [entry]);
|
|
67
|
+
leafBySession.set(entry.sessionId, entry.id);
|
|
68
|
+
}
|
|
69
|
+
return searchLinearSessions(bySession, leafBySession, query);
|
|
55
70
|
},
|
|
56
71
|
};
|
|
57
72
|
}
|
package/dist/observability.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
export function createProviderTurnMetadata(request, providerId, fields = {}) {
|
|
3
|
+
const names = (request.tools ?? []).map((tool) => tool.name);
|
|
2
4
|
return {
|
|
3
5
|
providerId,
|
|
4
6
|
model: request.model,
|
|
5
7
|
requestId: readRequestId(request),
|
|
6
8
|
...fields,
|
|
9
|
+
tools: {
|
|
10
|
+
count: names.length,
|
|
11
|
+
idsHash: `sha256:${createHash("sha256").update(JSON.stringify(names), "utf8").digest("hex")}`,
|
|
12
|
+
},
|
|
7
13
|
};
|
|
8
14
|
}
|
|
9
15
|
export function readProviderHttpStatus(error) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContentBlock, JsonObject, ProviderEvent, ToolCallContent, ToolCallDeltaContent, Usage } from "./contracts.js";
|
|
1
|
+
import type { ContentBlock, JsonObject, ProviderEvent, ProviderStopReason, ToolCallContent, ToolCallDeltaContent, Usage } from "./contracts.js";
|
|
2
2
|
export declare function providerTextDelta(text: string): ProviderEvent;
|
|
3
3
|
export declare function providerThinkingDelta(text: string, signature?: string): ProviderEvent;
|
|
4
4
|
export declare function providerContentDelta(content: ContentBlock): ProviderEvent;
|
|
@@ -13,7 +13,13 @@ export declare function providerToolCallDeltaContent(delta: Omit<ToolCallDeltaCo
|
|
|
13
13
|
export declare function providerContinuationRequired(cursor: string, reason?: string): ProviderEvent;
|
|
14
14
|
export declare function reconstructToolCallDeltas(events: readonly ProviderEvent[]): readonly ToolCallContent[];
|
|
15
15
|
export declare function providerUsage(usage: Usage): ProviderEvent;
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Map a native provider stop/finish reason onto the closed taxonomy. A missing, non-string, or
|
|
18
|
+
* unmapped value returns `"unknown"` rather than throwing, so a new wire value can never fail a
|
|
19
|
+
* run (plan 087 T1).
|
|
20
|
+
*/
|
|
21
|
+
export declare function mapProviderStopReason(native: string | null | undefined): ProviderStopReason;
|
|
22
|
+
export declare function providerDone(usage?: Usage, stopReason?: ProviderStopReason): ProviderEvent;
|
|
17
23
|
export declare function providerError(error: unknown, secrets?: readonly (string | undefined)[]): Extract<ProviderEvent, {
|
|
18
24
|
type: "error";
|
|
19
25
|
}>;
|
package/dist/provider-events.js
CHANGED
|
@@ -46,8 +46,66 @@ export function reconstructToolCallDeltas(events) {
|
|
|
46
46
|
export function providerUsage(usage) {
|
|
47
47
|
return { type: "usage", usage };
|
|
48
48
|
}
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
/**
|
|
50
|
+
* One shared native → taxonomy table (plan 087 T1). Every adapter routes its wire reason
|
|
51
|
+
* through `mapProviderStopReason`, so hosts switch on one closed union instead of per-provider
|
|
52
|
+
* strings. Keys are lowercased wire values; `unknown` never appears here — it is the fallback.
|
|
53
|
+
*/
|
|
54
|
+
const PROVIDER_STOP_REASONS = Object.freeze({
|
|
55
|
+
// OpenAI Chat Completions (`finish_reason`) and generic OpenAI-compatible routes.
|
|
56
|
+
stop: "end_turn",
|
|
57
|
+
length: "max_output_tokens",
|
|
58
|
+
tool_calls: "tool_calls",
|
|
59
|
+
function_call: "tool_calls",
|
|
60
|
+
content_filter: "content_filter",
|
|
61
|
+
// Messages-style `stop_reason` routes and Bedrock Converse (`stopReason`).
|
|
62
|
+
end_turn: "end_turn",
|
|
63
|
+
stop_sequence: "end_turn",
|
|
64
|
+
pause_turn: "end_turn",
|
|
65
|
+
tool_use: "tool_calls",
|
|
66
|
+
max_tokens: "max_output_tokens",
|
|
67
|
+
refusal: "content_filter",
|
|
68
|
+
// Google generateContent (`finishReason`).
|
|
69
|
+
safety: "content_filter",
|
|
70
|
+
recitation: "content_filter",
|
|
71
|
+
blocklist: "content_filter",
|
|
72
|
+
prohibited_content: "content_filter",
|
|
73
|
+
spii: "content_filter",
|
|
74
|
+
image_safety: "content_filter",
|
|
75
|
+
language: "content_filter",
|
|
76
|
+
malformed_function_call: "provider_error",
|
|
77
|
+
unexpected_tool_call: "provider_error",
|
|
78
|
+
// OpenAI Responses (status / `incomplete_details.reason`).
|
|
79
|
+
completed: "end_turn",
|
|
80
|
+
failed: "provider_error",
|
|
81
|
+
cancelled: "abort",
|
|
82
|
+
canceled: "abort",
|
|
83
|
+
// Bedrock Converse guarded routes.
|
|
84
|
+
content_filtered: "content_filter",
|
|
85
|
+
guardrail_intervened: "content_filter",
|
|
86
|
+
malformed_model_output: "provider_error",
|
|
87
|
+
malformed_tool_use: "provider_error",
|
|
88
|
+
// AI SDK unified finish reasons (hyphenated).
|
|
89
|
+
"content-filter": "content_filter",
|
|
90
|
+
"tool-calls": "tool_calls",
|
|
91
|
+
error: "provider_error",
|
|
92
|
+
other: "unknown",
|
|
93
|
+
unknown: "unknown",
|
|
94
|
+
abort: "abort",
|
|
95
|
+
aborted: "abort",
|
|
96
|
+
});
|
|
97
|
+
/**
|
|
98
|
+
* Map a native provider stop/finish reason onto the closed taxonomy. A missing, non-string, or
|
|
99
|
+
* unmapped value returns `"unknown"` rather than throwing, so a new wire value can never fail a
|
|
100
|
+
* run (plan 087 T1).
|
|
101
|
+
*/
|
|
102
|
+
export function mapProviderStopReason(native) {
|
|
103
|
+
if (typeof native !== "string")
|
|
104
|
+
return "unknown";
|
|
105
|
+
return PROVIDER_STOP_REASONS[native.trim().toLowerCase()] ?? "unknown";
|
|
106
|
+
}
|
|
107
|
+
export function providerDone(usage, stopReason) {
|
|
108
|
+
return { type: "done", usage, ...(stopReason === undefined ? {} : { stopReason }) };
|
|
51
109
|
}
|
|
52
110
|
export function providerError(error, secrets = []) {
|
|
53
111
|
const info = errorToErrorInfo(error, secrets);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveCredentialValue } from "../credentials.js";
|
|
2
|
-
import { providerDone, providerError, providerTextDelta, providerThinkingDelta, providerToolCall, providerToolCallDelta, providerUsage, toolCallFromArgumentsText, } from "../provider-events.js";
|
|
2
|
+
import { mapProviderStopReason, providerDone, providerError, providerTextDelta, providerThinkingDelta, providerToolCall, providerToolCallDelta, providerUsage, toolCallFromArgumentsText, } from "../provider-events.js";
|
|
3
3
|
import { assertStructuredOutputRequestSupported } from "../structured-output.js";
|
|
4
4
|
import { trimTrailingSlashes } from "../trim-trailing-slashes.js";
|
|
5
5
|
import { applyOpenAIChatStructuredOutput, assertOpenAIChatMessage, mapOpenAIChatUsage, serializeOpenAIChatMessage, serializeOpenAITool, } from "./openai-primitives.js";
|
|
@@ -13,6 +13,7 @@ export async function* openAIChatEvents(body, options = {}) {
|
|
|
13
13
|
let usage;
|
|
14
14
|
let sawDoneMarker = false;
|
|
15
15
|
let sawFinishReason = false;
|
|
16
|
+
let finishReason;
|
|
16
17
|
for await (const sseEvent of readSseEvents(body, { signal: options.signal })) {
|
|
17
18
|
if (options.onComment && sseEvent.comments?.length) {
|
|
18
19
|
for (const text of sseEvent.comments) {
|
|
@@ -43,8 +44,10 @@ export async function* openAIChatEvents(body, options = {}) {
|
|
|
43
44
|
yield providerUsage(mapped);
|
|
44
45
|
}
|
|
45
46
|
for (const choice of parsed.choices ?? []) {
|
|
46
|
-
if (choice.finish_reason)
|
|
47
|
+
if (choice.finish_reason) {
|
|
47
48
|
sawFinishReason = true;
|
|
49
|
+
finishReason = choice.finish_reason;
|
|
50
|
+
}
|
|
48
51
|
const delta = choice.delta ?? {};
|
|
49
52
|
if (typeof delta.content === "string" && delta.content)
|
|
50
53
|
yield providerTextDelta(delta.content);
|
|
@@ -83,7 +86,7 @@ export async function* openAIChatEvents(body, options = {}) {
|
|
|
83
86
|
for (const call of tools.values()) {
|
|
84
87
|
yield providerToolCall(toolCallFromArgumentsText(call.id, call.name, call.argumentsText));
|
|
85
88
|
}
|
|
86
|
-
yield providerDone((options.strictCompletion ?? true) || options.doneUsage ? usage : undefined);
|
|
89
|
+
yield providerDone((options.strictCompletion ?? true) || options.doneUsage ? usage : undefined, finishReason === undefined ? undefined : mapProviderStopReason(finishReason));
|
|
87
90
|
}
|
|
88
91
|
export function createOpenAICompatibleProvider(options) {
|
|
89
92
|
const providerId = options.id ?? "openai-compatible";
|
package/dist/run-bundle.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { agentFingerprint, BUILT_IN_LOOP_REVISIONS } from "./agent-run-state.js";
|
|
3
|
+
import { describeGuardrailPacks } from "./guardrails.js";
|
|
3
4
|
import { describeStorage } from "./host-composition.js";
|
|
4
5
|
import { canonicalizeJsonSchema } from "./providers/schema.js";
|
|
5
6
|
import { resolveRunLimits } from "./run-limits.js";
|
|
@@ -51,7 +52,7 @@ export function snapshotRunBundle(input) {
|
|
|
51
52
|
effect: tool.effect === undefined ? null : typeof tool.effect === "function" ? "classifier" : tool.effect.kind,
|
|
52
53
|
})),
|
|
53
54
|
activeSkills: run?.activeSkills ?? null,
|
|
54
|
-
guardrails: guardrailRows(config.guardrails, run?.guardrails),
|
|
55
|
+
guardrails: [...guardrailRows(config.guardrails, run?.guardrails), ...describeGuardrailPacks(input.config?.guardrailPacks)],
|
|
55
56
|
loop: loopIdentity(effectiveLoop),
|
|
56
57
|
thinkingLevel: run?.thinkingLevel ?? config.thinkingLevel ?? null,
|
|
57
58
|
limits: resolveRunLimits(config.limits, run?.limits),
|
package/dist/run-limits.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ResolvedRunLimits, RunLimitBreach, RunLimitCounters, RunLimitName, RunLimits, Usage } from "./contracts.js";
|
|
1
|
+
import type { BudgetAxisUsage, BudgetConsumedCounters, ResolvedRunLimits, RunLimitBreach, RunLimitCounters, RunLimitName, RunLimits, ToolCallSummary, Usage } from "./contracts.js";
|
|
2
2
|
export declare const DEFAULT_RUN_LIMITS: Readonly<{
|
|
3
3
|
maxTurns: 16;
|
|
4
4
|
maxProviderAttempts: 24;
|
|
@@ -51,3 +51,13 @@ export declare class RunLimitTracker {
|
|
|
51
51
|
private exceed;
|
|
52
52
|
}
|
|
53
53
|
export declare function createRunLimitTracker(limits: RunLimits | undefined, options?: RunLimitTrackerOptions): RunLimitTracker;
|
|
54
|
+
/**
|
|
55
|
+
* Build the `budget_exhausted` payload (plan 087 T2): which axis fired, the counters a host reads
|
|
56
|
+
* first, the closest other axes, and the last dispatched tool calls (hashes only).
|
|
57
|
+
*/
|
|
58
|
+
export declare function describeBudgetExhaustion(tracker: RunLimitTracker, breach: RunLimitBreach, recentToolCalls: readonly ToolCallSummary[]): {
|
|
59
|
+
limit: RunLimitName;
|
|
60
|
+
consumed: BudgetConsumedCounters;
|
|
61
|
+
closestOtherAxes: BudgetAxisUsage[];
|
|
62
|
+
recentToolCalls: ToolCallSummary[];
|
|
63
|
+
};
|
package/dist/run-limits.js
CHANGED
|
@@ -263,4 +263,50 @@ export class RunLimitTracker {
|
|
|
263
263
|
export function createRunLimitTracker(limits, options) {
|
|
264
264
|
return new RunLimitTracker(resolveRunLimits(undefined, limits), options);
|
|
265
265
|
}
|
|
266
|
+
/** Product axes reported as "how close was everything else" (plan 087 T2). Request/response bytes
|
|
267
|
+
* stay out: their caps are per-frame, so a run-lifetime ratio would be meaningless. */
|
|
268
|
+
const ATTRIBUTION_AXES = [
|
|
269
|
+
{ axis: "maxTurns", counter: "turns" },
|
|
270
|
+
{ axis: "maxProviderAttempts", counter: "providerAttempts" },
|
|
271
|
+
{ axis: "maxToolRounds", counter: "toolRounds" },
|
|
272
|
+
{ axis: "maxToolCalls", counter: "toolCalls" },
|
|
273
|
+
{ axis: "maxWallTimeMs", counter: "wallTimeMs" },
|
|
274
|
+
{ axis: "maxInputTokens", counter: "inputTokens" },
|
|
275
|
+
{ axis: "maxOutputTokens", counter: "outputTokens" },
|
|
276
|
+
{ axis: "maxTotalTokens", counter: "totalTokens" },
|
|
277
|
+
{ axis: "maxCost", counter: "cost" },
|
|
278
|
+
];
|
|
279
|
+
const CLOSEST_AXIS_COUNT = 3;
|
|
280
|
+
function axisCap(limits, axis) {
|
|
281
|
+
if (axis === "maxCost")
|
|
282
|
+
return limits.maxCost?.amount;
|
|
283
|
+
const value = limits[axis];
|
|
284
|
+
return typeof value === "number" ? value : null;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Build the `budget_exhausted` payload (plan 087 T2): which axis fired, the counters a host reads
|
|
288
|
+
* first, the closest other axes, and the last dispatched tool calls (hashes only).
|
|
289
|
+
*/
|
|
290
|
+
export function describeBudgetExhaustion(tracker, breach, recentToolCalls) {
|
|
291
|
+
const counters = tracker.snapshot();
|
|
292
|
+
const closestOtherAxes = ATTRIBUTION_AXES.filter(({ axis }) => axis !== breach.limit)
|
|
293
|
+
.flatMap(({ axis, counter }) => {
|
|
294
|
+
const cap = axisCap(tracker.limits, axis);
|
|
295
|
+
return typeof cap === "number" && cap > 0 ? [{ axis, cap, used: counters[counter] }] : [];
|
|
296
|
+
})
|
|
297
|
+
.map(({ axis, cap, used }) => ({ axis, usedRatio: Math.round(Math.min(1, used / cap) * 10_000) / 10_000 }))
|
|
298
|
+
.sort((a, b) => b.usedRatio - a.usedRatio)
|
|
299
|
+
.slice(0, CLOSEST_AXIS_COUNT);
|
|
300
|
+
return {
|
|
301
|
+
limit: breach.limit,
|
|
302
|
+
consumed: {
|
|
303
|
+
turns: counters.turns,
|
|
304
|
+
inputTokens: counters.inputTokens,
|
|
305
|
+
providerAttempts: counters.providerAttempts,
|
|
306
|
+
requestBytes: counters.requestBytes,
|
|
307
|
+
},
|
|
308
|
+
closestOtherAxes,
|
|
309
|
+
recentToolCalls: [...recentToolCalls],
|
|
310
|
+
};
|
|
311
|
+
}
|
|
266
312
|
//# sourceMappingURL=run-limits.js.map
|
package/dist/session-stores.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type BranchReader, type Message, type SessionBranchRead, type SessionEntry, type SessionStore } from "./contracts.js";
|
|
1
|
+
import { type BranchReader, type Message, type PersistencePage, type SessionBranchRead, type SessionEntry, type SessionSearchHit, type SessionSearchQuery, type SessionStore } from "./contracts.js";
|
|
2
2
|
export interface CreateSessionEntryOptions extends Omit<SessionEntry, "id" | "timestamp"> {
|
|
3
3
|
readonly id?: string;
|
|
4
4
|
readonly timestamp?: string;
|
|
@@ -40,4 +40,15 @@ export interface CreateMemorySessionStoreOptions {
|
|
|
40
40
|
readonly maxLinearBytes?: number;
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
|
+
/** Resolved bounds for the capped linear session scan. */
|
|
44
|
+
export interface LinearSearchCaps {
|
|
45
|
+
readonly sessions: number;
|
|
46
|
+
readonly entries: number;
|
|
47
|
+
readonly bytes: number;
|
|
48
|
+
}
|
|
43
49
|
export declare function createMemorySessionStore(initialEntries?: readonly SessionEntry[], options?: CreateMemorySessionStoreOptions): SessionStore;
|
|
50
|
+
/**
|
|
51
|
+
* Shared linear (unindexed) session search over already-grouped entries: the memory store's default
|
|
52
|
+
* mode and the JSONL store's implementation. `caps` defaults to the contract linear caps.
|
|
53
|
+
*/
|
|
54
|
+
export declare function searchLinearSessions(bySession: Map<string, SessionEntry[]>, leafBySession: Map<string, string>, query: SessionSearchQuery, caps?: LinearSearchCaps): PersistencePage<SessionSearchHit>;
|
package/dist/session-stores.js
CHANGED
|
@@ -133,7 +133,7 @@ export function createMemorySessionStore(initialEntries = [], options = {}) {
|
|
|
133
133
|
async searchSessions(query) {
|
|
134
134
|
if (mode === "unsupported")
|
|
135
135
|
throw new SessionSearchUnsupportedError();
|
|
136
|
-
return
|
|
136
|
+
return searchLinearSessions(bySession, leafBySession, query, searchCaps);
|
|
137
137
|
},
|
|
138
138
|
};
|
|
139
139
|
function add(entry, options) {
|
|
@@ -175,7 +175,11 @@ export function createMemorySessionStore(initialEntries = [], options = {}) {
|
|
|
175
175
|
leafBySession.set(entry.sessionId, entry.id);
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Shared linear (unindexed) session search over already-grouped entries: the memory store's default
|
|
180
|
+
* mode and the JSONL store's implementation. `caps` defaults to the contract linear caps.
|
|
181
|
+
*/
|
|
182
|
+
export function searchLinearSessions(bySession, leafBySession, query, caps = resolveLinearSearchCaps(undefined)) {
|
|
179
183
|
const q = resolveSessionSearchQuery(query);
|
|
180
184
|
q.signal?.throwIfAborted();
|
|
181
185
|
let sessionsScanned = 0;
|
|
@@ -203,13 +207,20 @@ function searchMemorySessionsLinear(bySession, leafBySession, query, caps) {
|
|
|
203
207
|
let matchedQuery = false;
|
|
204
208
|
let matchedProvider = false;
|
|
205
209
|
let matchedModel = false;
|
|
210
|
+
let matchedKind = false;
|
|
211
|
+
let matchedEntry;
|
|
206
212
|
let snippetSource;
|
|
213
|
+
let turn = 0;
|
|
207
214
|
for (const entry of entries) {
|
|
208
215
|
if (entriesScanned >= caps.entries)
|
|
209
216
|
break;
|
|
210
217
|
if (bytesScanned >= caps.bytes)
|
|
211
218
|
break;
|
|
212
219
|
entriesScanned += 1;
|
|
220
|
+
turn += 1;
|
|
221
|
+
const kindMatches = q.kind === undefined || q.kind.includes(entry.kind);
|
|
222
|
+
if (kindMatches)
|
|
223
|
+
matchedKind = true;
|
|
213
224
|
const text = entrySearchText(entry);
|
|
214
225
|
bytesScanned += utf8Bytes(text) + utf8Bytes(entry.label) + utf8Bytes(entry.summary);
|
|
215
226
|
if (entry.timestamp > updatedAt)
|
|
@@ -234,11 +245,12 @@ function searchMemorySessionsLinear(bySession, leafBySession, query, caps) {
|
|
|
234
245
|
matchedLabel = true;
|
|
235
246
|
if (q.summary && entry.summary?.includes(q.summary))
|
|
236
247
|
matchedSummary = true;
|
|
237
|
-
if (q.query) {
|
|
248
|
+
if (q.query && kindMatches) {
|
|
238
249
|
const hay = `${entry.label ?? ""}\n${entry.summary ?? ""}\n${text}`;
|
|
239
250
|
if (hay.includes(q.query)) {
|
|
240
251
|
matchedQuery = true;
|
|
241
|
-
|
|
252
|
+
matchedEntry ??= { entry, turn };
|
|
253
|
+
snippetSource ??= [entry.label, entry.summary, text].find((part) => part?.includes(q.query)) ?? hay;
|
|
242
254
|
}
|
|
243
255
|
}
|
|
244
256
|
if (q.provider && (entry.model?.provider === q.provider || metaProvider(entry) === q.provider))
|
|
@@ -260,6 +272,8 @@ function searchMemorySessionsLinear(bySession, leafBySession, query, caps) {
|
|
|
260
272
|
continue;
|
|
261
273
|
if (q.query && !matchedQuery)
|
|
262
274
|
continue;
|
|
275
|
+
if (q.kind && (q.query ? matchedEntry === undefined : !matchedKind))
|
|
276
|
+
continue;
|
|
263
277
|
if (q.provider && !matchedProvider)
|
|
264
278
|
continue;
|
|
265
279
|
if (q.model && !matchedModel)
|
|
@@ -271,6 +285,9 @@ function searchMemorySessionsLinear(bySession, leafBySession, query, caps) {
|
|
|
271
285
|
matches.push({
|
|
272
286
|
sessionId,
|
|
273
287
|
leafId: leafBySession.get(sessionId),
|
|
288
|
+
entryId: matchedEntry?.entry.id,
|
|
289
|
+
runId: matchedEntry?.entry.runId,
|
|
290
|
+
turn: matchedEntry?.turn,
|
|
274
291
|
updatedAt: updatedAt || undefined,
|
|
275
292
|
label,
|
|
276
293
|
summary,
|
|
@@ -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,
|
|
38
|
-
:
|
|
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,30 @@
|
|
|
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
|
+
/** Turn inputs; defaults are fixed strings so runs are comparable across hosts. */
|
|
14
|
+
readonly inputs?: readonly [string, string];
|
|
15
|
+
}
|
|
16
|
+
export interface PrefixStabilityConformanceResult {
|
|
17
|
+
/** Provider requests captured by the fixture (two per turn: skill load, then completion). */
|
|
18
|
+
readonly requests: number;
|
|
19
|
+
/** Lowest shared-prefix fraction observed across consecutive captured requests. */
|
|
20
|
+
readonly minContinuity: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Drive a real session through two staggered skill loads and assert that each
|
|
24
|
+
* provider request keeps a byte-identical leading prefix (messages **and** tool
|
|
25
|
+
* schemas) with its predecessor. Progressive disclosure appends a loaded body
|
|
26
|
+
* after the stable prefix, so the shared prefix stays intact; a host that
|
|
27
|
+
* rewrites the context block, the skill catalog, or any leading message per
|
|
28
|
+
* request fails with the offending request pair and the measured fraction.
|
|
29
|
+
*/
|
|
30
|
+
export declare function runPrefixStabilityConformance(options: PrefixStabilityConformanceOptions): Promise<PrefixStabilityConformanceResult>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// ponytail: dependency-free conformance runner for prompt-cache prefix stability.
|
|
2
|
+
// The runner owns the fixture provider (network-free, deterministic) and fixture
|
|
3
|
+
// skills; everything else in `host` is the caller's production assembly — system
|
|
4
|
+
// prompt, context providers, prompt builder, middleware, disclosure settings.
|
|
5
|
+
// Throws plain Error; no test runner, no network, no credentials.
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { createAgent } from "../agent-session/create-agent.js";
|
|
8
|
+
import { providerDone, toolCallContent } from "../provider-events.js";
|
|
9
|
+
import { createLoadSkillTool } from "../skill-load.js";
|
|
10
|
+
import { createSkillRegistry } from "../skills.js";
|
|
11
|
+
/**
|
|
12
|
+
* Drive a real session through two staggered skill loads and assert that each
|
|
13
|
+
* provider request keeps a byte-identical leading prefix (messages **and** tool
|
|
14
|
+
* schemas) with its predecessor. Progressive disclosure appends a loaded body
|
|
15
|
+
* after the stable prefix, so the shared prefix stays intact; a host that
|
|
16
|
+
* rewrites the context block, the skill catalog, or any leading message per
|
|
17
|
+
* request fails with the offending request pair and the measured fraction.
|
|
18
|
+
*/
|
|
19
|
+
export async function runPrefixStabilityConformance(options) {
|
|
20
|
+
const { host, skills } = options;
|
|
21
|
+
const minContinuity = options.minContinuity ?? 0.95;
|
|
22
|
+
const [first, second] = skills;
|
|
23
|
+
assert.notEqual(first.name, second.name, "prefix stability conformance needs two distinct skills");
|
|
24
|
+
const bodies = [];
|
|
25
|
+
for (const skill of skills) {
|
|
26
|
+
const instructions = skill.instructions;
|
|
27
|
+
assert.ok(typeof instructions === "string" && instructions.length > 0, `prefix stability conformance skill ${skill.name} needs non-empty instructions`);
|
|
28
|
+
bodies.push(instructions);
|
|
29
|
+
}
|
|
30
|
+
const requests = [];
|
|
31
|
+
const registry = createSkillRegistry([...skills]);
|
|
32
|
+
const hostTools = host.tools && "list" in host.tools ? host.tools.list() : (host.tools ?? []);
|
|
33
|
+
const agent = createAgent({
|
|
34
|
+
...host,
|
|
35
|
+
skills: registry,
|
|
36
|
+
tools: [...hostTools, createLoadSkillTool({ registry })],
|
|
37
|
+
provider: fixtureProvider(requests, [first.name, second.name]),
|
|
38
|
+
});
|
|
39
|
+
const session = agent.createSession();
|
|
40
|
+
const [firstInput, secondInput] = options.inputs ?? ["Prefix stability turn one", "Prefix stability turn two"];
|
|
41
|
+
const runOptions = { activeSkills: [first.name, second.name], limits: { maxToolRounds: 1 } };
|
|
42
|
+
await session.run(firstInput, runOptions);
|
|
43
|
+
await session.run(secondInput, runOptions);
|
|
44
|
+
assert.equal(requests.length, 4, `prefix stability conformance expected 4 provider requests (2 per staggered turn), captured ${requests.length}`);
|
|
45
|
+
const serialized = requests.map(serializeRequest);
|
|
46
|
+
// Guard against a vacuous pass: both bodies must have been disclosed by the end.
|
|
47
|
+
const last = serialized.at(-1) ?? "";
|
|
48
|
+
for (const [index, skill] of skills.entries()) {
|
|
49
|
+
assert.ok(last.includes(bodies[index] ?? ""), `prefix stability conformance: skill ${skill.name} body never reached the provider request — progressive disclosure did not expand it`);
|
|
50
|
+
}
|
|
51
|
+
let observed = 1;
|
|
52
|
+
let previous = serialized.at(0) ?? "";
|
|
53
|
+
for (let index = 1; index < serialized.length; index += 1) {
|
|
54
|
+
const next = serialized[index] ?? "";
|
|
55
|
+
const fraction = sharedPrefixFraction(previous, next);
|
|
56
|
+
observed = Math.min(observed, fraction);
|
|
57
|
+
assert.ok(fraction >= minContinuity, `prefix stability conformance: request ${index} → ${index + 1} kept ${(fraction * 100).toFixed(1)}% of the previous provider prefix ` +
|
|
58
|
+
`(minimum ${(minContinuity * 100).toFixed(1)}%). Late skill bodies must append after the stable prefix; ` +
|
|
59
|
+
"recomposed context, an in-place skill-catalog rewrite, or any leading-message mutation invalidates it.");
|
|
60
|
+
previous = next;
|
|
61
|
+
}
|
|
62
|
+
return { requests: serialized.length, minContinuity: observed };
|
|
63
|
+
}
|
|
64
|
+
/** Fixture provider: turn 1 loads `skillNames[0]`, turn 2 loads `skillNames[1]`, everything else completes. */
|
|
65
|
+
function fixtureProvider(requests, skillNames) {
|
|
66
|
+
let call = 0;
|
|
67
|
+
return {
|
|
68
|
+
id: "prefix-stability-fixture",
|
|
69
|
+
async *generate(request) {
|
|
70
|
+
requests.push(request);
|
|
71
|
+
const index = call;
|
|
72
|
+
call += 1;
|
|
73
|
+
const skillName = skillNames[index >> 1];
|
|
74
|
+
if (index % 2 === 0 && skillName !== undefined) {
|
|
75
|
+
yield { type: "tool_call", call: toolCallContent(`prefix-stability-${index}`, "load_skill", { name: skillName }) };
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
yield providerDone();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Provider-visible payload only: messages plus the tool schema fields sent on the wire. */
|
|
83
|
+
function serializeRequest(request) {
|
|
84
|
+
// One JSON fragment per message/tool so a structural array boundary never reads as a byte
|
|
85
|
+
// divergence: an appended message list stays an exact prefix of the next request.
|
|
86
|
+
const parts = [
|
|
87
|
+
...(request.tools ?? []).map((tool) => JSON.stringify({ name: tool.name, description: tool.description, parameters: tool.parameters })),
|
|
88
|
+
...request.messages.map((message) => JSON.stringify(message)),
|
|
89
|
+
];
|
|
90
|
+
return parts.join("\n");
|
|
91
|
+
}
|
|
92
|
+
/** Byte-shared prefix as a fraction of the previous request, so a shrink is a cache miss. */
|
|
93
|
+
function sharedPrefixFraction(previous, next) {
|
|
94
|
+
const before = Buffer.from(previous, "utf8");
|
|
95
|
+
const after = Buffer.from(next, "utf8");
|
|
96
|
+
if (before.length === 0)
|
|
97
|
+
return 1;
|
|
98
|
+
const limit = Math.min(before.length, after.length);
|
|
99
|
+
let shared = 0;
|
|
100
|
+
while (shared < limit && before[shared] === after[shared])
|
|
101
|
+
shared += 1;
|
|
102
|
+
return shared / before.length;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=prefix-stability-conformance.js.map
|
|
@@ -11,8 +11,9 @@ export interface SessionStoreConformanceOptions {
|
|
|
11
11
|
*/
|
|
12
12
|
readonly exerciseReadBranchPath?: boolean;
|
|
13
13
|
/**
|
|
14
|
-
* When true, exercises optional `searchSessions
|
|
15
|
-
*
|
|
14
|
+
* When true, exercises optional `searchSessions`: invalid limit/query/kind rejection via
|
|
15
|
+
* `resolveSessionSearchQuery` semantics, empty page, limit cap, a written-message query
|
|
16
|
+
* round-trip (`entryId`/`runId`/`turn`/`snippet`), the `kind` filter, and ownership bounds.
|
|
16
17
|
* Skipped when the store does not implement `searchSessions`.
|
|
17
18
|
*/
|
|
18
19
|
readonly exerciseSearchSessions?: boolean;
|