@arnilo/prism 0.10.0 → 0.11.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 +27 -1
- package/README.md +18 -16
- package/dist/agent-run-lifecycle.d.ts +2 -1
- package/dist/agent-run-lifecycle.js +1 -1
- package/dist/agent-session/session/assemble.js +9 -7
- package/dist/agent-session/session/tool-round.js +30 -20
- package/dist/agent-session/session/types.d.ts +1 -0
- package/dist/agent-session/session.d.ts +1 -0
- package/dist/agent-session/session.js +3 -2
- package/dist/checkpoint-restore.d.ts +50 -14
- package/dist/checkpoint-restore.js +104 -28
- package/dist/contracts-core/session.d.ts +2 -1
- package/dist/contracts-run-state.d.ts +12 -4
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/leases.js +32 -6
- package/dist/node/contribution-discovery.d.ts +16 -1
- package/dist/node/contribution-discovery.js +47 -0
- package/dist/node/session-store-jsonl.js +67 -17
- package/dist/run-limits.d.ts +11 -5
- package/dist/session-stores.js +61 -12
- package/dist/testing/prefix-stability-conformance.d.ts +44 -1
- package/dist/testing/prefix-stability-conformance.js +92 -29
- package/dist/usage-estimation.d.ts +7 -1
- package/dist/usage-estimation.js +16 -10
- package/docs/acp.md +2 -2
- package/docs/agent-events.md +7 -6
- package/docs/agent-session-runtime.md +1 -1
- package/docs/coding-agent-tools.md +1 -1
- package/docs/coding-tools.md +7 -11
- package/docs/context-and-skills.md +6 -7
- package/docs/contribution-discovery.md +13 -0
- package/docs/durable-runs.md +10 -3
- package/docs/embeddings.md +3 -1
- package/docs/execution-timeline.md +6 -0
- package/docs/extensions.md +1 -2
- package/docs/impeccable.md +1 -2
- package/docs/index.md +24 -20
- package/docs/live-testing.md +1 -2
- package/docs/memory-fabric.md +3 -2
- package/docs/migrate-to-0.11.md +65 -0
- package/docs/migration.md +12 -1
- package/docs/node-jsonl-session-store.md +4 -3
- package/docs/operations.md +1 -1
- package/docs/peer-dependencies.md +3 -5
- package/docs/policy-and-audit.md +1 -1
- package/docs/prefix-stability-conformance.md +30 -7
- package/docs/provider-packages.md +20 -20
- package/docs/public-contracts.md +1 -1
- package/docs/rag.md +2 -2
- package/docs/release-and-install.md +57 -57
- package/docs/runs-and-usage.md +6 -4
- package/docs/session-stores.md +2 -2
- package/docs/supervisors.md +14 -6
- package/docs/testing.md +17 -9
- package/docs/workflows.md +2 -2
- package/package.json +5 -4
- package/docs/caveman.md +0 -130
- package/docs/graft.md +0 -149
- package/docs/ponytail.md +0 -129
package/dist/index.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export type { CacheTelemetry, CacheTelemetryOptions, CacheTelemetryReport, Cache
|
|
|
17
17
|
export { CACHE_TELEMETRY_OVERFLOW_KEY, CacheTelemetryError, createCacheTelemetry, DEFAULT_CACHE_TELEMETRY_CAP, } from "./cache-telemetry.js";
|
|
18
18
|
export type { ProviderCapture, ProviderCaptureEntry, ProviderCaptureOptions, ProviderCapturePolicy } from "./capture.js";
|
|
19
19
|
export { createProviderCapture } from "./capture.js";
|
|
20
|
-
export type { CheckpointRestoreAudit, CheckpointRestoreAuditEntry, CheckpointRestoreHook, RunCheckpointRestoreHooksOptions, } from "./checkpoint-restore.js";
|
|
20
|
+
export type { CheckpointRestoreAudit, CheckpointRestoreAuditEntry, CheckpointRestoreCompensation, CheckpointRestoreHandler, CheckpointRestoreHook, RunCheckpointRestoreHooksOptions, } from "./checkpoint-restore.js";
|
|
21
21
|
export { CheckpointRestoreError, DEFAULT_CHECKPOINT_RESTORE_TIMEOUT_MS, runCheckpointRestoreHooks } from "./checkpoint-restore.js";
|
|
22
22
|
export type { MemoryCheckpointStoreOptions } from "./checkpoints.js";
|
|
23
23
|
export { CHECKPOINT_CONFLICT_CODE, CheckpointConflictError, createMemoryCheckpointStore } from "./checkpoints.js";
|
|
@@ -96,7 +96,7 @@ export type { RunBundleSnapshot, RunBundleSnapshotInput } from "./run-bundle.js"
|
|
|
96
96
|
export { RUN_BUNDLE_SCHEMA_VERSION, snapshotRunBundle } from "./run-bundle.js";
|
|
97
97
|
export type { BatchedRunLedgerOptions } from "./run-ledger.js";
|
|
98
98
|
export { createBatchedRunLedger, DEFAULT_LEDGER_BATCH_BYTES, DEFAULT_LEDGER_BATCH_DELAY_MS, DEFAULT_LEDGER_BATCH_ENTRIES, HARD_LEDGER_BATCH_BYTES, HARD_LEDGER_BATCH_DELAY_MS, HARD_LEDGER_BATCH_ENTRIES, isFlushableRunLedger, } from "./run-ledger.js";
|
|
99
|
-
export type { RunLimitTrackerOptions } from "./run-limits.js";
|
|
99
|
+
export type { BudgetExhaustionAttribution, RunLimitTrackerOptions } from "./run-limits.js";
|
|
100
100
|
export { createRunLimitTracker, DEFAULT_RUN_LIMITS, HARD_RUN_LIMITS, RunLimitError, RunLimitTracker, resolveRunLimits, } from "./run-limits.js";
|
|
101
101
|
export type { HostCompositionGovernance, HostCompositionOptions, HostCompositionProfile, HostCompositionReport, HostCompositionToolReport, } from "./secure-agent.js";
|
|
102
102
|
export { assertHostCompositionReadiness, createSecureAgent, HostCompositionError, inspectHostComposition, } from "./secure-agent.js";
|
|
@@ -131,5 +131,5 @@ export { MODEL_FAMILY_TOKENS, resolveModelFamily } from "./usage-estimation.js";
|
|
|
131
131
|
export type { ResolvedUseCaseModel, ResolveUseCaseModelInput, UseCaseModelBinding, } from "./use-case-model.js";
|
|
132
132
|
export { resolveUseCaseModel, resolveUseCaseModelBinding, useCaseCredentialProviderId, } from "./use-case-model.js";
|
|
133
133
|
export declare const name = "prism";
|
|
134
|
-
export declare const version = "0.
|
|
134
|
+
export declare const version = "0.11.0";
|
|
135
135
|
export declare const description = "Agent harness for AI providers, agents, sessions, and tools.";
|
package/dist/index.js
CHANGED
|
@@ -72,6 +72,6 @@ export { trimTrailingSlashes } from "./trim-trailing-slashes.js";
|
|
|
72
72
|
export { MODEL_FAMILY_TOKENS, resolveModelFamily } from "./usage-estimation.js";
|
|
73
73
|
export { resolveUseCaseModel, resolveUseCaseModelBinding, useCaseCredentialProviderId, } from "./use-case-model.js";
|
|
74
74
|
export const name = "prism";
|
|
75
|
-
export const version = "0.
|
|
75
|
+
export const version = "0.11.0";
|
|
76
76
|
export const description = "Agent harness for AI providers, agents, sessions, and tools.";
|
|
77
77
|
//# sourceMappingURL=index.js.map
|
package/dist/leases.js
CHANGED
|
@@ -6,10 +6,24 @@ export class LeaseConflictError extends Error {
|
|
|
6
6
|
this.name = "LeaseConflictError";
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
+
/** Drop expired rows once the map reaches this size. Live rows are never evicted. */
|
|
10
|
+
const EXPIRED_LEASE_SWEEP_AT = 1_024;
|
|
11
|
+
/** Test-only size probe. Not a LeaseStore field. */
|
|
12
|
+
const LEASE_RECORD_COUNT = Symbol.for("prism.lease.recordCount");
|
|
9
13
|
/** In-process reference implementation. Durable adapters provide cross-process exclusion. */
|
|
10
14
|
export function createMemoryLeaseStore() {
|
|
11
15
|
const records = new Map();
|
|
12
|
-
|
|
16
|
+
// ponytail: full scan only at EXPIRED_LEASE_SWEEP_AT, so a write is O(1) until then.
|
|
17
|
+
// Live leases are unbounded. Upgrade: cap live rows if a host holds more than the threshold.
|
|
18
|
+
function sweepExpired(now) {
|
|
19
|
+
if (records.size < EXPIRED_LEASE_SWEEP_AT)
|
|
20
|
+
return;
|
|
21
|
+
for (const [id, record] of records) {
|
|
22
|
+
if (Date.parse(record.expiresAt) <= now)
|
|
23
|
+
records.delete(id);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const store = {
|
|
13
27
|
async tryAcquireLease(input) {
|
|
14
28
|
validateAcquire(input);
|
|
15
29
|
const id = keyOf(input);
|
|
@@ -17,7 +31,9 @@ export function createMemoryLeaseStore() {
|
|
|
17
31
|
if (current)
|
|
18
32
|
assertOwnership(input, current);
|
|
19
33
|
const now = Date.now();
|
|
20
|
-
|
|
34
|
+
sweepExpired(now);
|
|
35
|
+
const held = records.get(id);
|
|
36
|
+
if (held && Date.parse(held.expiresAt) > now)
|
|
21
37
|
return null;
|
|
22
38
|
const timestamp = new Date(now).toISOString();
|
|
23
39
|
const record = {
|
|
@@ -25,7 +41,7 @@ export function createMemoryLeaseStore() {
|
|
|
25
41
|
key: input.key,
|
|
26
42
|
ownerId: input.ownerId,
|
|
27
43
|
token: crypto.randomUUID(),
|
|
28
|
-
fencingToken: (
|
|
44
|
+
fencingToken: (held?.fencingToken ?? 0) + 1,
|
|
29
45
|
acquiredAt: timestamp,
|
|
30
46
|
expiresAt: new Date(now + input.ttlMs).toISOString(),
|
|
31
47
|
updatedAt: timestamp,
|
|
@@ -37,30 +53,38 @@ export function createMemoryLeaseStore() {
|
|
|
37
53
|
async renewLease(input) {
|
|
38
54
|
validateClaim(input, true);
|
|
39
55
|
const current = records.get(keyOf(input));
|
|
40
|
-
if (!current)
|
|
56
|
+
if (!current) {
|
|
57
|
+
sweepExpired(Date.now());
|
|
41
58
|
return null;
|
|
59
|
+
}
|
|
42
60
|
assertOwnership(input, current);
|
|
43
61
|
const now = Date.now();
|
|
44
|
-
if (current.ownerId !== input.ownerId || current.token !== input.token || Date.parse(current.expiresAt) <= now)
|
|
62
|
+
if (current.ownerId !== input.ownerId || current.token !== input.token || Date.parse(current.expiresAt) <= now) {
|
|
63
|
+
sweepExpired(now);
|
|
45
64
|
return null;
|
|
65
|
+
}
|
|
46
66
|
const record = {
|
|
47
67
|
...current,
|
|
48
68
|
expiresAt: new Date(now + input.ttlMs).toISOString(),
|
|
49
69
|
updatedAt: new Date(now).toISOString(),
|
|
50
70
|
};
|
|
51
71
|
records.set(keyOf(input), record);
|
|
72
|
+
sweepExpired(now);
|
|
52
73
|
return record;
|
|
53
74
|
},
|
|
54
75
|
async releaseLease(input) {
|
|
55
76
|
validateClaim(input, false);
|
|
56
77
|
const current = records.get(keyOf(input));
|
|
57
|
-
if (!current)
|
|
78
|
+
if (!current) {
|
|
79
|
+
sweepExpired(Date.now());
|
|
58
80
|
return false;
|
|
81
|
+
}
|
|
59
82
|
assertOwnership(input, current);
|
|
60
83
|
if (current.ownerId !== input.ownerId || current.token !== input.token)
|
|
61
84
|
return false;
|
|
62
85
|
const now = new Date().toISOString();
|
|
63
86
|
records.set(keyOf(input), { ...current, expiresAt: now, updatedAt: now });
|
|
87
|
+
sweepExpired(Date.now());
|
|
64
88
|
return true;
|
|
65
89
|
},
|
|
66
90
|
async getLease(input) {
|
|
@@ -72,6 +96,8 @@ export function createMemoryLeaseStore() {
|
|
|
72
96
|
return Date.parse(current.expiresAt) > Date.now() ? current : null;
|
|
73
97
|
},
|
|
74
98
|
};
|
|
99
|
+
Object.defineProperty(store, LEASE_RECORD_COUNT, { get: () => records.size });
|
|
100
|
+
return store;
|
|
75
101
|
}
|
|
76
102
|
function keyOf(input) {
|
|
77
103
|
return `${input.namespace}\0${input.key}`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContributionFileKind, DiscoveredContribution } from "../contracts.js";
|
|
1
|
+
import type { ContributionFileKind, DiscoveredContribution, Skill } from "../contracts.js";
|
|
2
2
|
import type { PermissionPolicy, TrustPolicy } from "../security.js";
|
|
3
3
|
export interface DiscoveryOptions {
|
|
4
4
|
readonly kinds: readonly ContributionFileKind[];
|
|
@@ -14,3 +14,18 @@ export interface DiscoveryOptions {
|
|
|
14
14
|
*/
|
|
15
15
|
export declare function discoverContributions(options: DiscoveryOptions): Promise<readonly DiscoveredContribution[]>;
|
|
16
16
|
export declare function readOptionalFile(path: string): Promise<string | undefined>;
|
|
17
|
+
export interface LoadSkillDirectoryOptions {
|
|
18
|
+
/** Per-file byte cap; defaults to `HARD_MAX_SKILL_INSTRUCTION_BYTES` (262 144). */
|
|
19
|
+
readonly maxSkillBytes?: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Load a host-supplied `<directory>/<name>/SKILL.md` tree (an upstream persona
|
|
23
|
+
* or provider checkout) into inert {@link Skill} values. Bounded per file,
|
|
24
|
+
* symlink-contained, and deterministic (sorted by directory name); nothing is
|
|
25
|
+
* auto-activated — the host registers the result through `api.registerSkill` or
|
|
26
|
+
* its own loader.
|
|
27
|
+
*
|
|
28
|
+
* Throws when `directory` is unreadable or a SKILL.md exceeds the cap. A
|
|
29
|
+
* subdirectory without SKILL.md (or a symlink escaping `directory`) is skipped.
|
|
30
|
+
*/
|
|
31
|
+
export declare function loadSkillDirectory(directory: string, options?: LoadSkillDirectoryOptions): Promise<readonly Skill[]>;
|
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { isJsonObject } from "../config.js";
|
|
4
4
|
import { parseSkillFile } from "../contribution-parsing.js";
|
|
5
5
|
import { assertPermission } from "../security.js";
|
|
6
|
+
import { HARD_MAX_SKILL_INSTRUCTION_BYTES } from "../skill-disclosure.js";
|
|
6
7
|
import { isNodeErrorCode } from "./config.js";
|
|
7
8
|
import { isPathInsideReal } from "./trust.js";
|
|
8
9
|
/**
|
|
@@ -103,6 +104,52 @@ export async function readOptionalFile(path) {
|
|
|
103
104
|
throw error;
|
|
104
105
|
}
|
|
105
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Load a host-supplied `<directory>/<name>/SKILL.md` tree (an upstream persona
|
|
109
|
+
* or provider checkout) into inert {@link Skill} values. Bounded per file,
|
|
110
|
+
* symlink-contained, and deterministic (sorted by directory name); nothing is
|
|
111
|
+
* auto-activated — the host registers the result through `api.registerSkill` or
|
|
112
|
+
* its own loader.
|
|
113
|
+
*
|
|
114
|
+
* Throws when `directory` is unreadable or a SKILL.md exceeds the cap. A
|
|
115
|
+
* subdirectory without SKILL.md (or a symlink escaping `directory`) is skipped.
|
|
116
|
+
*/
|
|
117
|
+
export async function loadSkillDirectory(directory, options = {}) {
|
|
118
|
+
const maxBytes = options.maxSkillBytes ?? HARD_MAX_SKILL_INSTRUCTION_BYTES;
|
|
119
|
+
let names;
|
|
120
|
+
try {
|
|
121
|
+
names = await readdir(directory);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
throw new Error(`No readable skill directory at ${directory}: ${error instanceof Error ? error.message : String(error)}`);
|
|
125
|
+
}
|
|
126
|
+
const skills = [];
|
|
127
|
+
for (const name of [...names].sort()) {
|
|
128
|
+
const dir = join(directory, name);
|
|
129
|
+
let isDir;
|
|
130
|
+
try {
|
|
131
|
+
isDir = (await stat(dir)).isDirectory();
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (isNodeErrorCode(error, "ENOENT"))
|
|
135
|
+
continue;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
if (!isDir || !(await isPathInsideReal(directory, dir)))
|
|
139
|
+
continue;
|
|
140
|
+
const path = join(dir, "SKILL.md");
|
|
141
|
+
if (!(await isPathInsideReal(directory, path)))
|
|
142
|
+
continue;
|
|
143
|
+
const text = await readOptionalFile(path);
|
|
144
|
+
if (text === undefined)
|
|
145
|
+
continue;
|
|
146
|
+
if (Buffer.byteLength(text, "utf8") > maxBytes) {
|
|
147
|
+
throw new Error(`Skill file ${path} exceeds ${maxBytes} byte cap`);
|
|
148
|
+
}
|
|
149
|
+
skills.push(parseSkillFile(text, path));
|
|
150
|
+
}
|
|
151
|
+
return skills;
|
|
152
|
+
}
|
|
106
153
|
// --- Internal helpers. ---
|
|
107
154
|
// ponytail: parseSkillFile/parseAgentFile live in ../contribution-parsing.ts (core, fs-free); this
|
|
108
155
|
// Node module owns directory walking + manifest.json parsing only.
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
1
|
+
import { appendFile, mkdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { isSessionEntryKind, SESSION_APPEND_CONFLICT_CODE, SESSION_ENTRY_SCHEMA_VERSION, SessionAppendConflictError, } from "../contracts.js";
|
|
4
4
|
import { searchLinearSessions } from "../session-stores.js";
|
|
5
5
|
import { isNodeErrorCode } from "./config.js";
|
|
6
|
+
const IDEMPOTENCY_SEEN_MAX = 4_096;
|
|
7
|
+
function rememberIdempotencyKey(seen, key) {
|
|
8
|
+
if (seen.size >= IDEMPOTENCY_SEEN_MAX) {
|
|
9
|
+
const oldest = seen.values().next().value;
|
|
10
|
+
if (oldest !== undefined)
|
|
11
|
+
seen.delete(oldest);
|
|
12
|
+
}
|
|
13
|
+
seen.add(key);
|
|
14
|
+
}
|
|
15
|
+
/** Test-only read counter on the store instance. Not a SessionStore field. */
|
|
16
|
+
const JSONL_FILE_READS = Symbol.for("prism.jsonl.fileReads");
|
|
6
17
|
export function createJsonlSessionStore(pathOrOptions) {
|
|
7
18
|
const options = typeof pathOrOptions === "string" ? { path: pathOrOptions, createDirectory: true } : pathOrOptions;
|
|
8
19
|
const path = options.path;
|
|
@@ -13,14 +24,50 @@ export function createJsonlSessionStore(pathOrOptions) {
|
|
|
13
24
|
// lock). The expectedParentId/idempotency guards below mirror the memory store;
|
|
14
25
|
// a DB adapter enforces them via a conditional transaction + unique index.
|
|
15
26
|
const idempotencySeen = new Set();
|
|
16
|
-
|
|
27
|
+
// ponytail: cache key is (size, mtimeMs). A same-size rewrite inside one filesystem
|
|
28
|
+
// timestamp tick is invisible. Upgrade: hash the file. Post-write size !== prior
|
|
29
|
+
// size + bytes written already drops the cache so the next read re-parses.
|
|
30
|
+
let cache;
|
|
31
|
+
let fileReads = 0;
|
|
32
|
+
async function readParsed() {
|
|
33
|
+
fileReads++;
|
|
34
|
+
return readJsonlSessionEntries(path);
|
|
35
|
+
}
|
|
36
|
+
async function readEntries() {
|
|
37
|
+
let st;
|
|
38
|
+
try {
|
|
39
|
+
const info = await stat(path);
|
|
40
|
+
st = { size: info.size, mtimeMs: info.mtimeMs };
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (isNodeErrorCode(error, "ENOENT")) {
|
|
44
|
+
cache = undefined;
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`Failed to read session store ${path}: ${errorMessage(error)}`);
|
|
48
|
+
}
|
|
49
|
+
if (cache && cache.size === st.size && cache.mtimeMs === st.mtimeMs)
|
|
50
|
+
return cache.entries;
|
|
51
|
+
const result = await readParsed();
|
|
52
|
+
cache = { size: st.size, mtimeMs: st.mtimeMs, entries: result.entries };
|
|
53
|
+
return result.entries;
|
|
54
|
+
}
|
|
55
|
+
const store = {
|
|
17
56
|
append(entry, appendOptions) {
|
|
18
57
|
const operation = appendChain
|
|
19
58
|
.catch(() => undefined)
|
|
20
59
|
.then(async () => {
|
|
21
60
|
if (options.createDirectory !== false)
|
|
22
61
|
await mkdir(dirname(path), { recursive: true });
|
|
23
|
-
|
|
62
|
+
let beforeSize = 0;
|
|
63
|
+
try {
|
|
64
|
+
beforeSize = (await stat(path)).size;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (!isNodeErrorCode(error, "ENOENT"))
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
const readResult = await readParsed();
|
|
24
71
|
if (readResult.errors.length > 0) {
|
|
25
72
|
const first = readResult.errors[0];
|
|
26
73
|
throw new Error(`Invalid JSONL at line ${first.line}: ${first.message}`);
|
|
@@ -39,23 +86,30 @@ export function createJsonlSessionStore(pathOrOptions) {
|
|
|
39
86
|
if (entries.some((existing) => existing.id === entry.id))
|
|
40
87
|
throw new Error(`Duplicate session entry id: ${entry.id}`);
|
|
41
88
|
if (dedupKey !== undefined)
|
|
42
|
-
idempotencySeen
|
|
43
|
-
|
|
89
|
+
rememberIdempotencyKey(idempotencySeen, dedupKey);
|
|
90
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
91
|
+
await appendFile(path, line, "utf8");
|
|
92
|
+
const info = await stat(path);
|
|
93
|
+
if (info.size === beforeSize + Buffer.byteLength(line)) {
|
|
94
|
+
cache = { size: info.size, mtimeMs: info.mtimeMs, entries: entries.concat(entry) };
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
cache = undefined;
|
|
98
|
+
}
|
|
44
99
|
});
|
|
45
100
|
appendChain = operation.catch(() => undefined);
|
|
46
101
|
return operation;
|
|
47
102
|
},
|
|
48
103
|
async list(sessionId) {
|
|
49
|
-
return (await readEntries(
|
|
104
|
+
return (await readEntries()).filter((entry) => entry.sessionId === sessionId);
|
|
50
105
|
},
|
|
51
106
|
async get(id) {
|
|
52
|
-
return
|
|
107
|
+
return (await readEntries()).find((entry) => entry.id === id);
|
|
53
108
|
},
|
|
54
109
|
async searchSessions(query) {
|
|
55
|
-
// ponytail: no index
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
const { entries } = await readJsonlSessionEntries(path);
|
|
110
|
+
// ponytail: no index. Cache miss still reads and parses the file (O(corpus)); hit reuses the
|
|
111
|
+
// parsed array. Indexed path is SQLite/Postgres. Corrupt lines stay quarantined like list()/get().
|
|
112
|
+
const entries = await readEntries();
|
|
59
113
|
const bySession = new Map();
|
|
60
114
|
const leafBySession = new Map();
|
|
61
115
|
for (const entry of entries) {
|
|
@@ -69,6 +123,8 @@ export function createJsonlSessionStore(pathOrOptions) {
|
|
|
69
123
|
return searchLinearSessions(bySession, leafBySession, query);
|
|
70
124
|
},
|
|
71
125
|
};
|
|
126
|
+
Object.defineProperty(store, JSONL_FILE_READS, { get: () => fileReads });
|
|
127
|
+
return store;
|
|
72
128
|
}
|
|
73
129
|
/** Read a JSONL session file and return both valid entries and per-line parse errors. */
|
|
74
130
|
export async function readJsonlSessionEntries(path) {
|
|
@@ -94,12 +150,6 @@ export async function readJsonlSessionEntries(path) {
|
|
|
94
150
|
}
|
|
95
151
|
return { entries, errors };
|
|
96
152
|
}
|
|
97
|
-
async function findEntry(path, id) {
|
|
98
|
-
return (await readEntries(path)).find((entry) => entry.id === id);
|
|
99
|
-
}
|
|
100
|
-
async function readEntries(path) {
|
|
101
|
-
return (await readJsonlSessionEntries(path)).entries;
|
|
102
|
-
}
|
|
103
153
|
function parseEntry(line, lineNumber) {
|
|
104
154
|
let parsed;
|
|
105
155
|
try {
|
package/dist/run-limits.d.ts
CHANGED
|
@@ -51,13 +51,19 @@ export declare class RunLimitTracker {
|
|
|
51
51
|
private exceed;
|
|
52
52
|
}
|
|
53
53
|
export declare function createRunLimitTracker(limits: RunLimits | undefined, options?: RunLimitTrackerOptions): RunLimitTracker;
|
|
54
|
+
/**
|
|
55
|
+
* Attribution carried by a limit death (plan 087 T2, plan 108 T5): the `budget_exhausted` payload
|
|
56
|
+
* without `limit`, which `AgentRunResult.limit` / `child_failed.limit` already carry.
|
|
57
|
+
*/
|
|
58
|
+
export interface BudgetExhaustionAttribution {
|
|
59
|
+
readonly consumed: BudgetConsumedCounters;
|
|
60
|
+
readonly closestOtherAxes: readonly BudgetAxisUsage[];
|
|
61
|
+
readonly recentToolCalls: readonly ToolCallSummary[];
|
|
62
|
+
}
|
|
54
63
|
/**
|
|
55
64
|
* Build the `budget_exhausted` payload (plan 087 T2): which axis fired, the counters a host reads
|
|
56
65
|
* first, the closest other axes, and the last dispatched tool calls (hashes only).
|
|
57
66
|
*/
|
|
58
|
-
export declare function describeBudgetExhaustion(tracker: RunLimitTracker, breach: RunLimitBreach, recentToolCalls: readonly ToolCallSummary[]): {
|
|
59
|
-
limit: RunLimitName;
|
|
60
|
-
consumed: BudgetConsumedCounters;
|
|
61
|
-
closestOtherAxes: BudgetAxisUsage[];
|
|
62
|
-
recentToolCalls: ToolCallSummary[];
|
|
67
|
+
export declare function describeBudgetExhaustion(tracker: RunLimitTracker, breach: RunLimitBreach, recentToolCalls: readonly ToolCallSummary[]): BudgetExhaustionAttribution & {
|
|
68
|
+
readonly limit: RunLimitName;
|
|
63
69
|
};
|
package/dist/session-stores.js
CHANGED
|
@@ -27,11 +27,11 @@ async function readBranchFromReader(reader, query) {
|
|
|
27
27
|
if (!cursor)
|
|
28
28
|
break;
|
|
29
29
|
}
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
return
|
|
30
|
+
// Reader returns an ancestor set in any order. Order once and keep those objects.
|
|
31
|
+
// Detachment is the store's clone (readBranchPath / list), not a second walk here.
|
|
32
|
+
return orderBranch(items, { leafId: query.leafId });
|
|
33
33
|
}
|
|
34
|
-
function
|
|
34
|
+
function orderBranch(entries, options = {}, index = indexEntries(entries)) {
|
|
35
35
|
const leafId = options.leafId ?? entries.at(-1)?.id;
|
|
36
36
|
if (!leafId)
|
|
37
37
|
return [];
|
|
@@ -45,7 +45,11 @@ function getSessionBranchEntriesCore(entries, options = {}, index = indexEntries
|
|
|
45
45
|
branch.push(entry);
|
|
46
46
|
id = entry.parentId;
|
|
47
47
|
}
|
|
48
|
-
|
|
48
|
+
branch.reverse();
|
|
49
|
+
return branch;
|
|
50
|
+
}
|
|
51
|
+
function getSessionBranchEntriesCore(entries, options = {}, index = indexEntries(entries)) {
|
|
52
|
+
return orderBranch(entries, options, index).map(cloneEntry);
|
|
49
53
|
}
|
|
50
54
|
export function listSessionBranches(entries) {
|
|
51
55
|
const index = indexEntries(entries);
|
|
@@ -60,13 +64,15 @@ export function rebuildSessionContext(input, options = {}) {
|
|
|
60
64
|
return rebuildSessionContextCore(input, options);
|
|
61
65
|
}
|
|
62
66
|
async function rebuildSessionContextFromReader(reader, query) {
|
|
63
|
-
// ponytail: pass the
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
return
|
|
67
|
+
// ponytail: one order pass on the reader's pages. No second walk and no second clone —
|
|
68
|
+
// stores that implement readBranchPath already detach. Upgrade path: clone here again if a
|
|
69
|
+
// host reader returns live store objects and a snapshot mutation is observed in the store.
|
|
70
|
+
return snapshotFromBranch(await readBranchFromReader(reader, query));
|
|
67
71
|
}
|
|
68
72
|
function rebuildSessionContextCore(entries, options = {}) {
|
|
69
|
-
|
|
73
|
+
return snapshotFromBranch(getSessionBranchEntriesCore(entries, options));
|
|
74
|
+
}
|
|
75
|
+
function snapshotFromBranch(branch) {
|
|
70
76
|
const compaction = [...branch]
|
|
71
77
|
.reverse()
|
|
72
78
|
.find((entry) => entry.kind === "compaction" && entry.summary && isCompactionEntryData(entry.data));
|
|
@@ -90,7 +96,7 @@ function rebuildSessionContextCore(entries, options = {}) {
|
|
|
90
96
|
if (entry.id === compaction.id)
|
|
91
97
|
continue;
|
|
92
98
|
if (entry.kind === "message" && entry.message && (afterThrough || keepIds.has(entry.id)))
|
|
93
|
-
messages.push(
|
|
99
|
+
messages.push(entry.message);
|
|
94
100
|
if (entry.kind === "summary" && entry.summary && afterThrough)
|
|
95
101
|
summaries.push(entry.summary);
|
|
96
102
|
}
|
|
@@ -135,6 +141,18 @@ export function createMemorySessionStore(initialEntries = [], options = {}) {
|
|
|
135
141
|
throw new SessionSearchUnsupportedError();
|
|
136
142
|
return searchLinearSessions(bySession, leafBySession, query, searchCaps);
|
|
137
143
|
},
|
|
144
|
+
async readBranchPath(query) {
|
|
145
|
+
const entries = bySession.get(query.sessionId) ?? [];
|
|
146
|
+
const leafId = query.leafId ?? tipLeafId(entries);
|
|
147
|
+
if (!leafId)
|
|
148
|
+
return { items: [] };
|
|
149
|
+
const chain = orderBranch(entries, { leafId });
|
|
150
|
+
const start = query.cursor !== undefined ? decodeBranchOffset(query.cursor) : 0;
|
|
151
|
+
const limit = query.limit ?? chain.length;
|
|
152
|
+
const items = chain.slice(start, start + limit).map(cloneEntry);
|
|
153
|
+
const next = start + items.length;
|
|
154
|
+
return { items, nextCursor: next < chain.length ? String(next) : undefined };
|
|
155
|
+
},
|
|
138
156
|
};
|
|
139
157
|
function add(entry, options) {
|
|
140
158
|
// ponytail: idempotency dedup keyed on (session, key, expectedParentId) so a
|
|
@@ -167,7 +185,7 @@ export function createMemorySessionStore(initialEntries = [], options = {}) {
|
|
|
167
185
|
if (byId.has(entry.id))
|
|
168
186
|
throw new Error(`Duplicate session entry id: ${entry.id}`);
|
|
169
187
|
if (dedupKey !== undefined)
|
|
170
|
-
idempotencySeen
|
|
188
|
+
rememberIdempotencyKey(idempotencySeen, dedupKey);
|
|
171
189
|
byId.set(entry.id, entry);
|
|
172
190
|
const entries = bySession.get(entry.sessionId) ?? [];
|
|
173
191
|
entries.push(entry);
|
|
@@ -352,9 +370,40 @@ function isAfterSearchCursor(hit, cursor, order) {
|
|
|
352
370
|
}
|
|
353
371
|
return at < cursor.updatedAt || (at === cursor.updatedAt && hit.sessionId < cursor.sessionId);
|
|
354
372
|
}
|
|
373
|
+
/** Dedup only covers retries near the append. Older keys append as new entries. */
|
|
374
|
+
const IDEMPOTENCY_SEEN_MAX = 4_096;
|
|
375
|
+
function rememberIdempotencyKey(seen, key) {
|
|
376
|
+
if (seen.size >= IDEMPOTENCY_SEEN_MAX) {
|
|
377
|
+
const oldest = seen.values().next().value;
|
|
378
|
+
if (oldest !== undefined)
|
|
379
|
+
seen.delete(oldest);
|
|
380
|
+
}
|
|
381
|
+
seen.add(key);
|
|
382
|
+
}
|
|
355
383
|
function cloneEntry(entry) {
|
|
356
384
|
return structuredClone(entry);
|
|
357
385
|
}
|
|
386
|
+
function decodeBranchOffset(cursor) {
|
|
387
|
+
const value = Number(cursor);
|
|
388
|
+
if (!Number.isInteger(value) || value < 0)
|
|
389
|
+
throw new Error("Invalid branch pagination cursor");
|
|
390
|
+
return value;
|
|
391
|
+
}
|
|
392
|
+
/** Tip with the latest timestamp, then highest id. Matches SQLite `findLatestLeafId`. */
|
|
393
|
+
function tipLeafId(entries) {
|
|
394
|
+
const parents = new Set();
|
|
395
|
+
for (const entry of entries)
|
|
396
|
+
if (entry.parentId)
|
|
397
|
+
parents.add(entry.parentId);
|
|
398
|
+
let best;
|
|
399
|
+
for (const entry of entries) {
|
|
400
|
+
if (parents.has(entry.id))
|
|
401
|
+
continue;
|
|
402
|
+
if (!best || entry.timestamp > best.timestamp || (entry.timestamp === best.timestamp && entry.id > best.id))
|
|
403
|
+
best = entry;
|
|
404
|
+
}
|
|
405
|
+
return best?.id;
|
|
406
|
+
}
|
|
358
407
|
function isCompactionEntryData(value) {
|
|
359
408
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
360
409
|
return false;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentConfig, Skill } from "../contracts.js";
|
|
1
|
+
import type { AgentConfig, Message, ProviderRequest, Skill } from "../contracts.js";
|
|
2
2
|
export interface PrefixStabilityConformanceOptions {
|
|
3
3
|
/**
|
|
4
4
|
* The host's own agent config, minus `provider`, `providerSource`, and `skills`:
|
|
@@ -25,6 +25,13 @@ export interface PrefixStabilityConformanceOptions {
|
|
|
25
25
|
* fixture was supposed to invalidate the prefix, so a run that never did cannot pass vacuously.
|
|
26
26
|
*/
|
|
27
27
|
readonly allowedResets?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Install a runner-owned fixture tool (name constant) that returns exactly this many bytes of
|
|
30
|
+
* generated text, and add a second tool call for it alongside `load_skill` in the same round, so
|
|
31
|
+
* the attention compiler's tool-result stage has one row worth stubbing. The payload is
|
|
32
|
+
* `"x".repeat(bytes)`, never host content. Unset: the capture is byte-identical to today.
|
|
33
|
+
*/
|
|
34
|
+
readonly foldableToolResultBytes?: number;
|
|
28
35
|
}
|
|
29
36
|
export interface PrefixStabilityConformanceResult {
|
|
30
37
|
/** Provider requests captured by the fixture (two per turn: skill load, then completion). */
|
|
@@ -43,7 +50,43 @@ export interface PrefixStabilityConformanceResult {
|
|
|
43
50
|
* prefix instead of appending. Empty when every gap stayed above the minimum.
|
|
44
51
|
*/
|
|
45
52
|
readonly resets: readonly number[];
|
|
53
|
+
/**
|
|
54
|
+
* One row per `resets` entry, same order. `fraction` is the metric `assertOn` selected;
|
|
55
|
+
* `cacheableFraction` is the tail-excluded fraction for that pair. Empty when `resets` is.
|
|
56
|
+
*/
|
|
57
|
+
readonly resetDetails: readonly PrefixStabilityResetDetail[];
|
|
58
|
+
}
|
|
59
|
+
export interface ScorePrefixStabilityOptions {
|
|
60
|
+
/** Session tail map. Omitted or unmatched: both fractions stay equal — never a fabricated `1`. */
|
|
61
|
+
readonly tailSegments?: ReadonlyMap<string, Message>;
|
|
62
|
+
/** Minimum shared byte-prefix fraction. Default `0.95`. Selects which pairs land in `resets`. */
|
|
63
|
+
readonly minContinuity?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Which fraction `resets` follows. Default `"providerPrefix"` (the wire prefix). The runner
|
|
66
|
+
* passes its own `assertOn` so fixture resets stay on the selected metric without a second pass.
|
|
67
|
+
*/
|
|
68
|
+
readonly assertOn?: "providerPrefix" | "cacheablePrefix";
|
|
69
|
+
}
|
|
70
|
+
/** One reset: index plus both fractions. Numbers only — no message text. */
|
|
71
|
+
export interface PrefixStabilityResetDetail {
|
|
72
|
+
/** 1-based index of the later request in the pair. */
|
|
73
|
+
readonly request: number;
|
|
74
|
+
/** Fraction of the metric `assertOn` selected for the pair. Default: provider-visible. */
|
|
75
|
+
readonly fraction: number;
|
|
76
|
+
/** The same pair with tail segments removed. */
|
|
77
|
+
readonly cacheableFraction: number;
|
|
78
|
+
}
|
|
79
|
+
export interface PrefixStabilitySample {
|
|
80
|
+
readonly minContinuity: number;
|
|
81
|
+
readonly cacheableContinuity: number;
|
|
82
|
+
readonly resets: readonly number[];
|
|
83
|
+
readonly resetDetails: readonly PrefixStabilityResetDetail[];
|
|
46
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Score an already-captured request list. One pass, no session, no provider call.
|
|
87
|
+
* A wrong `tailSegments` map yields a wrong number, not a throw.
|
|
88
|
+
*/
|
|
89
|
+
export declare function scorePrefixStability(requests: readonly ProviderRequest[], options?: ScorePrefixStabilityOptions): PrefixStabilitySample;
|
|
47
90
|
/**
|
|
48
91
|
* Drive a real session through two staggered skill loads and assert that each
|
|
49
92
|
* provider request keeps a byte-identical leading prefix (messages **and** tool
|