@arnilo/prism 0.0.5 → 0.0.7
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 +39 -1
- package/dist/agent-loops.d.ts +1 -0
- package/dist/agent-loops.js +27 -16
- package/dist/agent-run-lifecycle.d.ts +28 -0
- package/dist/agent-run-lifecycle.js +33 -0
- package/dist/agent-run-state.d.ts +53 -0
- package/dist/agent-run-state.js +127 -0
- package/dist/agents.d.ts +3 -1
- package/dist/agents.js +337 -46
- package/dist/contracts.d.ts +205 -3
- package/dist/contracts.js +4 -0
- package/dist/guardrails.d.ts +25 -0
- package/dist/guardrails.js +133 -0
- package/dist/ids.d.ts +2 -0
- package/dist/ids.js +6 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.js +10 -3
- package/dist/input.js +2 -0
- package/dist/resources.js +2 -1
- package/dist/run-limits.d.ts +34 -0
- package/dist/run-limits.js +163 -0
- package/dist/secure-agent.d.ts +3 -0
- package/dist/secure-agent.js +63 -0
- package/dist/session-stores.js +2 -3
- package/dist/testing/persistence-schema.d.ts +45 -7
- package/dist/testing/persistence-schema.js +138 -24
- package/dist/thinking.d.ts +42 -0
- package/dist/thinking.js +92 -0
- package/dist/tools.d.ts +10 -2
- package/dist/tools.js +56 -7
- package/dist/use-case-model.d.ts +63 -0
- package/dist/use-case-model.js +52 -0
- package/docs/a2a.md +4 -2
- package/docs/agent-events.md +23 -16
- package/docs/agent-loops.md +19 -8
- package/docs/agent-session-runtime.md +33 -1
- package/docs/coding-agent-tools.md +33 -12
- package/docs/coding-security.md +2 -2
- package/docs/compaction-llm.md +17 -7
- package/docs/compaction-observational-memory.md +28 -4
- package/docs/credential-storage.md +58 -9
- package/docs/credentials-and-redaction.md +1 -1
- package/docs/database-persistence.md +8 -3
- package/docs/guardrails.md +75 -0
- package/docs/host-security.md +16 -8
- package/docs/index.md +26 -22
- package/docs/mcp-tools.md +32 -12
- package/docs/migration.md +164 -2
- package/docs/node-filesystem-config.md +1 -0
- package/docs/node-jsonl-session-store.md +5 -4
- package/docs/postgres-persistence.md +3 -3
- package/docs/provider-caching.md +16 -4
- package/docs/provider-conformance.md +39 -1
- package/docs/provider-packages.md +60 -3
- package/docs/providers/ai-sdk.md +36 -0
- package/docs/providers/kimi.md +124 -61
- package/docs/providers/neuralwatt.md +19 -13
- package/docs/providers/openai.md +56 -13
- package/docs/providers/opencode-go.md +118 -30
- package/docs/providers/openrouter.md +105 -35
- package/docs/providers/zai.md +94 -45
- package/docs/release-and-install.md +47 -49
- package/docs/review-coverage-2026-07-17-provider-validation.md +192 -0
- package/docs/runs-and-usage.md +30 -3
- package/docs/server.md +5 -2
- package/docs/sqlite-persistence.md +2 -2
- package/docs/structured-output.md +1 -1
- package/docs/thinking-and-reasoning.md +98 -0
- package/docs/tool-execution-primitives.md +3 -3
- package/docs/tools.md +21 -1
- package/docs/use-case-model-selection.md +109 -0
- package/docs/workflow-orchestration-primitives.md +1 -0
- package/docs/workflows.md +18 -10
- package/docs/working-and-semantic-memory.md +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
export const DEFAULT_RUN_LIMITS = Object.freeze({
|
|
2
|
+
maxTurns: 16,
|
|
3
|
+
maxProviderAttempts: 24,
|
|
4
|
+
maxToolRounds: 8,
|
|
5
|
+
maxToolCalls: 32,
|
|
6
|
+
maxWallTimeMs: 120_000,
|
|
7
|
+
maxRequestBytes: 8 * 1024 * 1024,
|
|
8
|
+
maxResponseBytes: 8 * 1024 * 1024,
|
|
9
|
+
maxInputTokens: 40_000,
|
|
10
|
+
maxOutputTokens: 10_000,
|
|
11
|
+
maxTotalTokens: 50_000,
|
|
12
|
+
});
|
|
13
|
+
export const HARD_MAX_RUN_COST = 10_000;
|
|
14
|
+
export const HARD_RUN_LIMITS = Object.freeze({
|
|
15
|
+
maxTurns: 64,
|
|
16
|
+
maxProviderAttempts: 256,
|
|
17
|
+
maxToolRounds: 64,
|
|
18
|
+
maxToolCalls: 256,
|
|
19
|
+
maxWallTimeMs: 30 * 60_000,
|
|
20
|
+
maxRequestBytes: 64 * 1024 * 1024,
|
|
21
|
+
maxResponseBytes: 64 * 1024 * 1024,
|
|
22
|
+
maxInputTokens: 1_000_000,
|
|
23
|
+
maxOutputTokens: 250_000,
|
|
24
|
+
maxTotalTokens: 1_000_000,
|
|
25
|
+
});
|
|
26
|
+
const LIMIT_NAMES = Object.keys(DEFAULT_RUN_LIMITS);
|
|
27
|
+
const COUNTER_FOR = {
|
|
28
|
+
maxTurns: "turns",
|
|
29
|
+
maxProviderAttempts: "providerAttempts",
|
|
30
|
+
maxToolRounds: "toolRounds",
|
|
31
|
+
maxToolCalls: "toolCalls",
|
|
32
|
+
maxWallTimeMs: "wallTimeMs",
|
|
33
|
+
maxRequestBytes: "requestBytes",
|
|
34
|
+
maxResponseBytes: "responseBytes",
|
|
35
|
+
maxInputTokens: "inputTokens",
|
|
36
|
+
maxOutputTokens: "outputTokens",
|
|
37
|
+
maxTotalTokens: "totalTokens",
|
|
38
|
+
maxCost: "cost",
|
|
39
|
+
};
|
|
40
|
+
export class RunLimitError extends Error {
|
|
41
|
+
breach;
|
|
42
|
+
code = "ERR_PRISM_RUN_LIMIT";
|
|
43
|
+
constructor(breach) {
|
|
44
|
+
super(`Run limit exceeded: ${breach.limit}`);
|
|
45
|
+
this.breach = breach;
|
|
46
|
+
this.name = "RunLimitError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Validate one host-authored layer. Defaults are applied only after inheritance is resolved. */
|
|
50
|
+
export function resolveRunLimits(agent, run) {
|
|
51
|
+
const base = agent ? validateLimits(agent) : undefined;
|
|
52
|
+
const override = run ? validateLimits(run) : undefined;
|
|
53
|
+
const resolved = { ...DEFAULT_RUN_LIMITS };
|
|
54
|
+
for (const name of LIMIT_NAMES) {
|
|
55
|
+
if (base?.[name] !== undefined)
|
|
56
|
+
resolved[name] = base[name];
|
|
57
|
+
if (override?.[name] !== undefined)
|
|
58
|
+
resolved[name] = base ? Math.min(resolved[name], override[name]) : override[name];
|
|
59
|
+
}
|
|
60
|
+
const maxCost = override?.maxCost ?? base?.maxCost;
|
|
61
|
+
return Object.freeze({ ...resolved, ...(maxCost ? { maxCost: base?.maxCost && override?.maxCost ? { amount: Math.min(base.maxCost.amount, override.maxCost.amount), currency: base.maxCost.currency === override.maxCost.currency ? base.maxCost.currency : failCurrency() } : maxCost } : {}) });
|
|
62
|
+
}
|
|
63
|
+
function failCurrency() { throw new TypeError("Run limit currencies must match when narrowed"); }
|
|
64
|
+
function validateLimits(input) {
|
|
65
|
+
for (const name of LIMIT_NAMES) {
|
|
66
|
+
const value = input[name];
|
|
67
|
+
if (value === undefined)
|
|
68
|
+
continue;
|
|
69
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > HARD_RUN_LIMITS[name]) {
|
|
70
|
+
throw new TypeError(`${name} must be a positive safe integer at most ${HARD_RUN_LIMITS[name]}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (input.maxCost) {
|
|
74
|
+
const { amount, currency } = input.maxCost;
|
|
75
|
+
if (!Number.isFinite(amount) || amount < 0 || amount > HARD_MAX_RUN_COST || !currency.trim())
|
|
76
|
+
throw new TypeError(`maxCost requires a finite amount from 0 through ${HARD_MAX_RUN_COST} and currency`);
|
|
77
|
+
}
|
|
78
|
+
return input;
|
|
79
|
+
}
|
|
80
|
+
export class RunLimitTracker {
|
|
81
|
+
options;
|
|
82
|
+
limits;
|
|
83
|
+
startedAt = performance.now();
|
|
84
|
+
deadlineAt;
|
|
85
|
+
counters;
|
|
86
|
+
timer;
|
|
87
|
+
exceeded;
|
|
88
|
+
constructor(limits, options = {}) {
|
|
89
|
+
this.options = options;
|
|
90
|
+
this.limits = limits;
|
|
91
|
+
this.counters = { turns: 0, providerAttempts: 0, toolRounds: 0, toolCalls: 0, wallTimeMs: 0, requestBytes: 0, responseBytes: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0, cost: 0, ...options.snapshot };
|
|
92
|
+
for (const [key, value] of Object.entries(this.counters)) {
|
|
93
|
+
if (!Number.isFinite(value) || value < 0 || (key !== "cost" && !Number.isSafeInteger(value))) {
|
|
94
|
+
throw new TypeError("Run limit snapshot must contain finite non-negative counters");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const deadline = options.deadlineAt ? Date.parse(options.deadlineAt) : Date.now() + limits.maxWallTimeMs;
|
|
98
|
+
if (!Number.isFinite(deadline))
|
|
99
|
+
throw new TypeError("Run limit deadlineAt is invalid");
|
|
100
|
+
this.deadlineAt = new Date(deadline).toISOString();
|
|
101
|
+
const remaining = Math.max(0, deadline - Date.now());
|
|
102
|
+
this.timer = setTimeout(() => this.exceed("maxWallTimeMs", limits.maxWallTimeMs), remaining);
|
|
103
|
+
this.timer.unref?.();
|
|
104
|
+
if (remaining === 0)
|
|
105
|
+
this.exceed("maxWallTimeMs", limits.maxWallTimeMs);
|
|
106
|
+
}
|
|
107
|
+
get breach() { return this.exceeded; }
|
|
108
|
+
snapshot() { return { ...this.counters, wallTimeMs: Math.min(this.limits.maxWallTimeMs, Math.ceil(performance.now() - this.startedAt)) }; }
|
|
109
|
+
dispose() { if (this.timer)
|
|
110
|
+
clearTimeout(this.timer); this.timer = undefined; }
|
|
111
|
+
charge(limit, delta = 1) {
|
|
112
|
+
if (!Number.isSafeInteger(delta) || delta < 0)
|
|
113
|
+
throw new TypeError("Run limit delta must be a non-negative safe integer");
|
|
114
|
+
const counter = COUNTER_FOR[limit];
|
|
115
|
+
const observed = this.counters[counter] + delta;
|
|
116
|
+
if (!Number.isSafeInteger(observed))
|
|
117
|
+
this.exceed(limit, Number.MAX_SAFE_INTEGER + 1);
|
|
118
|
+
this.counters[counter] = observed;
|
|
119
|
+
if (observed > this.limits[limit])
|
|
120
|
+
this.exceed(limit, observed);
|
|
121
|
+
}
|
|
122
|
+
recordUsage(usage) {
|
|
123
|
+
if (!usage) {
|
|
124
|
+
if (this.limits.maxCost)
|
|
125
|
+
this.exceed("maxCost", Number.POSITIVE_INFINITY);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
for (const key of ["inputTokens", "outputTokens", "totalTokens", "cacheReadTokens", "cacheWriteTokens"]) {
|
|
129
|
+
const value = usage[key];
|
|
130
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0))
|
|
131
|
+
throw new TypeError(`Provider usage ${key} must be a non-negative safe integer`);
|
|
132
|
+
}
|
|
133
|
+
const total = usage.totalTokens ?? ((usage.inputTokens ?? 0) + (usage.outputTokens ?? 0));
|
|
134
|
+
if (!Number.isSafeInteger(total))
|
|
135
|
+
throw new TypeError("Provider usage totalTokens is invalid");
|
|
136
|
+
this.charge("maxInputTokens", usage.inputTokens ?? 0);
|
|
137
|
+
this.charge("maxOutputTokens", usage.outputTokens ?? 0);
|
|
138
|
+
this.charge("maxTotalTokens", total);
|
|
139
|
+
if (usage.cost !== undefined && (!Number.isFinite(usage.cost) || usage.cost < 0))
|
|
140
|
+
throw new TypeError("Provider usage cost must be finite and non-negative");
|
|
141
|
+
if (!this.limits.maxCost)
|
|
142
|
+
return;
|
|
143
|
+
if (usage.cost === undefined || usage.currency !== this.limits.maxCost.currency)
|
|
144
|
+
this.exceed("maxCost", Number.POSITIVE_INFINITY);
|
|
145
|
+
const observed = this.counters.cost + usage.cost;
|
|
146
|
+
this.counters.cost = observed;
|
|
147
|
+
if (observed > this.limits.maxCost.amount)
|
|
148
|
+
this.exceed("maxCost", observed);
|
|
149
|
+
}
|
|
150
|
+
exceed(limit, observed) {
|
|
151
|
+
if (!this.exceeded) {
|
|
152
|
+
const maximum = limit === "maxCost" ? this.limits.maxCost?.amount ?? 0 : this.limits[limit];
|
|
153
|
+
this.exceeded = { limit, maximum, observed, ...(limit === "maxCost" && this.limits.maxCost ? { currency: this.limits.maxCost.currency } : {}) };
|
|
154
|
+
this.options.onExceeded?.(this.exceeded);
|
|
155
|
+
}
|
|
156
|
+
if (limit !== "maxWallTimeMs")
|
|
157
|
+
throw new RunLimitError(this.exceeded);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export function createRunLimitTracker(limits, options) {
|
|
161
|
+
return new RunLimitTracker(resolveRunLimits(undefined, limits), options);
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=run-limits.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createAgent } from "./agents.js";
|
|
2
|
+
import { validateRunStateOptions } from "./agent-run-state.js";
|
|
3
|
+
import { resolveRunLimits } from "./run-limits.js";
|
|
4
|
+
import { createToolParameterValidator, createToolRegistry } from "./tools.js";
|
|
5
|
+
/** Build an opt-in agent whose security-critical defaults cannot be replaced per run. */
|
|
6
|
+
export function createSecureAgent(options) {
|
|
7
|
+
if (!options.id.trim())
|
|
8
|
+
throw new TypeError("Secure agent requires a non-empty id");
|
|
9
|
+
if (!options.definitionRevision.trim())
|
|
10
|
+
throw new TypeError("Secure agent requires a non-empty definitionRevision");
|
|
11
|
+
if (!options.redactor || typeof options.redactor.redact !== "function")
|
|
12
|
+
throw new TypeError("Secure agent requires a redactor");
|
|
13
|
+
if (!options.permission || typeof options.permission.check !== "function")
|
|
14
|
+
throw new TypeError("Secure agent requires a permission policy");
|
|
15
|
+
if (!options.trust || typeof options.trust.check !== "function")
|
|
16
|
+
throw new TypeError("Secure agent requires a trust policy");
|
|
17
|
+
if (!options.toolArgumentValidator || typeof options.toolArgumentValidator.validate !== "function")
|
|
18
|
+
throw new TypeError("Secure agent requires a tool argument validator");
|
|
19
|
+
if (!options.limits || Object.keys(options.limits).length === 0)
|
|
20
|
+
throw new TypeError("Secure agent requires explicit limits");
|
|
21
|
+
if (!options.ownership || !Object.values(options.ownership).some((value) => typeof value === "string" && value.trim()))
|
|
22
|
+
throw new TypeError("Secure agent requires non-empty ownership");
|
|
23
|
+
for (const tool of options.tools) {
|
|
24
|
+
if (!tool.name.trim())
|
|
25
|
+
throw new TypeError("Secure agent tool names must be non-empty");
|
|
26
|
+
if (!tool.parameters || Object.keys(tool.parameters).length === 0)
|
|
27
|
+
throw new TypeError(`Secure agent tool ${tool.name} requires a non-empty parameters schema`);
|
|
28
|
+
}
|
|
29
|
+
resolveRunLimits(options.limits);
|
|
30
|
+
const runState = Object.freeze({ ...options.runState, definitionRevision: options.definitionRevision, interruptBeforeTool: true });
|
|
31
|
+
validateRunStateOptions(runState);
|
|
32
|
+
const config = Object.freeze({
|
|
33
|
+
...withoutSecureFields(options),
|
|
34
|
+
id: options.id,
|
|
35
|
+
tools: createToolRegistry(options.tools, { duplicate: "error" }),
|
|
36
|
+
validator: createToolParameterValidator(options.toolArgumentValidator, { missingSchema: "reject" }),
|
|
37
|
+
redactor: options.redactor,
|
|
38
|
+
permission: options.permission,
|
|
39
|
+
trust: options.trust,
|
|
40
|
+
ownership: Object.freeze({ ...options.ownership }),
|
|
41
|
+
limits: Object.freeze({ ...options.limits }),
|
|
42
|
+
guardrails: freezeGuardrails(options.guardrails),
|
|
43
|
+
runState,
|
|
44
|
+
secure: true,
|
|
45
|
+
});
|
|
46
|
+
return createAgent(config);
|
|
47
|
+
}
|
|
48
|
+
function withoutSecureFields(options) {
|
|
49
|
+
const { tools: _tools, toolArgumentValidator: _validator, redactor: _redactor, permission: _permission, trust: _trust, ownership: _ownership, limits: _limits, guardrails: _guardrails, definitionRevision: _revision, runState: _runState, ...config } = options;
|
|
50
|
+
return config;
|
|
51
|
+
}
|
|
52
|
+
function freezeGuardrails(guardrails) {
|
|
53
|
+
if (!guardrails)
|
|
54
|
+
return undefined;
|
|
55
|
+
return Object.freeze({
|
|
56
|
+
...guardrails,
|
|
57
|
+
input: Object.freeze([...(guardrails.input ?? [])]),
|
|
58
|
+
output: Object.freeze([...(guardrails.output ?? [])]),
|
|
59
|
+
toolInput: Object.freeze([...(guardrails.toolInput ?? [])]),
|
|
60
|
+
toolOutput: Object.freeze([...(guardrails.toolOutput ?? [])]),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=secure-agent.js.map
|
package/dist/session-stores.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SESSION_APPEND_CONFLICT_CODE, SessionAppendConflictError } from "./contracts.js";
|
|
2
|
+
import { createId } from "./ids.js";
|
|
2
3
|
export function createSessionEntry(options) {
|
|
3
4
|
const { createId, now, ...entry } = options;
|
|
4
5
|
return {
|
|
@@ -171,7 +172,5 @@ function indexEntries(entries) {
|
|
|
171
172
|
}
|
|
172
173
|
return { byId, parentIds };
|
|
173
174
|
}
|
|
174
|
-
|
|
175
|
-
return `${prefix}_${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
|
|
176
|
-
}
|
|
175
|
+
const randomId = createId;
|
|
177
176
|
//# sourceMappingURL=session-stores.js.map
|
|
@@ -7,6 +7,8 @@ export interface PersistenceColumnDefinition {
|
|
|
7
7
|
readonly name: string;
|
|
8
8
|
readonly type: PersistenceColumnType;
|
|
9
9
|
readonly nullable?: boolean;
|
|
10
|
+
/** Portable SQL default literal, if the schema requires one. */
|
|
11
|
+
readonly defaultValue?: string;
|
|
10
12
|
/** Column participates in tenant isolation boundaries when true. */
|
|
11
13
|
readonly tenantScoped?: boolean;
|
|
12
14
|
}
|
|
@@ -42,6 +44,8 @@ export interface PersistenceMigrationStep {
|
|
|
42
44
|
readonly version: number;
|
|
43
45
|
readonly name: string;
|
|
44
46
|
readonly description?: string;
|
|
47
|
+
/** SHA-256 of the canonical checked-in migration schema content. */
|
|
48
|
+
readonly checksum: string;
|
|
45
49
|
}
|
|
46
50
|
/** Versioned migration expectations shared by production database adapters. */
|
|
47
51
|
export interface PersistenceMigrationContract {
|
|
@@ -74,6 +78,46 @@ export declare function tenantScopedUniqueKey(baseColumns: readonly string[], te
|
|
|
74
78
|
export declare function assertPersistenceSchemaModel(model: PersistenceSchemaModel): void;
|
|
75
79
|
/** Assert migration steps are strictly increasing and end at the target schema version. */
|
|
76
80
|
export declare function assertPersistenceMigrationContract(contract: PersistenceMigrationContract): void;
|
|
81
|
+
export type PersistenceSchemaDialect = "sqlite" | "postgres";
|
|
82
|
+
export interface PersistenceSchemaShapeColumn {
|
|
83
|
+
readonly name: string;
|
|
84
|
+
readonly type: string;
|
|
85
|
+
readonly nullable: boolean;
|
|
86
|
+
readonly defaultValue?: string;
|
|
87
|
+
}
|
|
88
|
+
export interface PersistenceSchemaShapeForeignKey {
|
|
89
|
+
readonly columns: readonly string[];
|
|
90
|
+
readonly referencesTable: string;
|
|
91
|
+
readonly referencesColumns: readonly string[];
|
|
92
|
+
}
|
|
93
|
+
export interface PersistenceSchemaShapeTable {
|
|
94
|
+
readonly name: string;
|
|
95
|
+
readonly columns: readonly PersistenceSchemaShapeColumn[];
|
|
96
|
+
readonly primaryKey: readonly string[];
|
|
97
|
+
readonly uniqueKeys: readonly (readonly string[])[];
|
|
98
|
+
readonly foreignKeys: readonly PersistenceSchemaShapeForeignKey[];
|
|
99
|
+
}
|
|
100
|
+
export interface PersistenceSchemaShapeIndex {
|
|
101
|
+
readonly name: string;
|
|
102
|
+
readonly table: string;
|
|
103
|
+
readonly columns: readonly string[];
|
|
104
|
+
readonly unique: boolean;
|
|
105
|
+
}
|
|
106
|
+
export interface PersistenceSchemaShape {
|
|
107
|
+
readonly tables: readonly PersistenceSchemaShapeTable[];
|
|
108
|
+
readonly indexes: readonly PersistenceSchemaShapeIndex[];
|
|
109
|
+
}
|
|
110
|
+
/** Compare bounded dialect catalog output against every required schema-v3 detail. */
|
|
111
|
+
export declare function assertPersistenceSchemaShape(shape: PersistenceSchemaShape, dialect: PersistenceSchemaDialect, model?: PersistenceSchemaModel): void;
|
|
112
|
+
export interface AppliedPersistenceMigration {
|
|
113
|
+
readonly name: string;
|
|
114
|
+
readonly version: string;
|
|
115
|
+
readonly checksum: string | null;
|
|
116
|
+
}
|
|
117
|
+
/** Reject altered migration history before any new DDL or runtime write. */
|
|
118
|
+
export declare function assertAppliedPersistenceMigrations(contract: PersistenceMigrationContract, applied: readonly AppliedPersistenceMigration[]): {
|
|
119
|
+
readonly legacyChecksums: boolean;
|
|
120
|
+
};
|
|
77
121
|
/**
|
|
78
122
|
* Assert a dialect-local adapter exposes the canonical table and index names.
|
|
79
123
|
* Adapters pass the table/index names their migration runner created.
|
|
@@ -82,13 +126,7 @@ export declare function assertAdapterSchemaMatchesModel(adapterTables: readonly
|
|
|
82
126
|
/** Guard adapter SQL tests: reject obvious value interpolation into statement text. */
|
|
83
127
|
export declare function assertParameterizedQuery(sql: string, boundValues: readonly unknown[]): void;
|
|
84
128
|
/** Simulate migration up + reopen: applied steps must match the contract in order. */
|
|
85
|
-
export declare function assertMigrationUpAndReopen(contract: PersistenceMigrationContract, appliedAfterUp: readonly
|
|
86
|
-
readonly name: string;
|
|
87
|
-
readonly version: string;
|
|
88
|
-
}[], appliedAfterReopen: readonly {
|
|
89
|
-
readonly name: string;
|
|
90
|
-
readonly version: string;
|
|
91
|
-
}[]): void;
|
|
129
|
+
export declare function assertMigrationUpAndReopen(contract: PersistenceMigrationContract, appliedAfterUp: readonly AppliedPersistenceMigration[], appliedAfterReopen: readonly AppliedPersistenceMigration[]): void;
|
|
92
130
|
export interface PersistenceQueryConformanceFixture {
|
|
93
131
|
readonly seedEntries: (entries: readonly SessionEntry[]) => Promise<void> | void;
|
|
94
132
|
readonly queryEntries: (query: SessionEntryQuery) => Promise<PersistencePage<SessionEntry>>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
// ponytail: dialect-neutral persistence schema model and migration contracts for
|
|
2
3
|
// SQLite/PostgreSQL adapter packages (Plan 056 Task 1). SQL stays package-local;
|
|
3
4
|
// this module defines the shared table/index/pagination/migration expectations
|
|
@@ -107,7 +108,7 @@ export function createPersistenceSchemaModel() {
|
|
|
107
108
|
{ name: "run_id", type: "text", nullable: true },
|
|
108
109
|
{ name: "timestamp", type: "timestamp" },
|
|
109
110
|
{ name: "kind", type: "text" },
|
|
110
|
-
{ name: "schema_version", type: "integer" },
|
|
111
|
+
{ name: "schema_version", type: "integer", nullable: true },
|
|
111
112
|
{ name: "message", type: "json", nullable: true },
|
|
112
113
|
{ name: "event", type: "json", nullable: true },
|
|
113
114
|
{ name: "model", type: "json", nullable: true },
|
|
@@ -156,7 +157,6 @@ export function createPersistenceSchemaModel() {
|
|
|
156
157
|
...TENANT_COLUMNS,
|
|
157
158
|
{ name: "metadata", type: "json", nullable: true },
|
|
158
159
|
],
|
|
159
|
-
uniqueKeys: [["tenant_id", "idempotency_key"]],
|
|
160
160
|
foreignKeys: [{ columns: ["session_id"], referencesTable: "prism_sessions", referencesColumns: ["id"] }],
|
|
161
161
|
},
|
|
162
162
|
{
|
|
@@ -210,7 +210,7 @@ export function createPersistenceSchemaModel() {
|
|
|
210
210
|
{ name: "session_id", type: "text" },
|
|
211
211
|
{ name: "run_id", type: "text", nullable: true },
|
|
212
212
|
{ name: "entry_id", type: "text", nullable: true },
|
|
213
|
-
{ name: "scope", type: "text" },
|
|
213
|
+
{ name: "scope", type: "text", defaultValue: "'run_total'" },
|
|
214
214
|
{ name: "turn", type: "integer", nullable: true },
|
|
215
215
|
{ name: "attempt", type: "integer", nullable: true },
|
|
216
216
|
{ name: "usage", type: "json" },
|
|
@@ -235,7 +235,9 @@ export function createPersistenceSchemaModel() {
|
|
|
235
235
|
{ name: "evaluation_ids", type: "json" },
|
|
236
236
|
{ name: "created_at", type: "timestamp" },
|
|
237
237
|
{ name: "created_by", type: "text", nullable: true },
|
|
238
|
-
|
|
238
|
+
{ name: "tenant_id", type: "text", tenantScoped: true },
|
|
239
|
+
{ name: "account_id", type: "text", nullable: true, tenantScoped: true },
|
|
240
|
+
{ name: "user_id", type: "text", nullable: true, tenantScoped: true },
|
|
239
241
|
{ name: "metadata", type: "json", nullable: true },
|
|
240
242
|
],
|
|
241
243
|
foreignKeys: [{ columns: ["run_id"], referencesTable: "prism_runs", referencesColumns: ["id"] }],
|
|
@@ -281,7 +283,7 @@ export function createPersistenceSchemaModel() {
|
|
|
281
283
|
{ name: "prism_session_entries_session_run_ts_idx", table: "prism_session_entries", columns: ["session_id", "run_id", "timestamp"], purpose: "run-scoped entry listing" },
|
|
282
284
|
{ name: "prism_session_entries_session_ts_id_idx", table: "prism_session_entries", columns: ["session_id", "timestamp", "id"], purpose: "cursor pagination without full scans" },
|
|
283
285
|
{ name: "prism_session_entries_session_id_idx", table: "prism_session_entries", columns: ["session_id", "id"], purpose: "append parent validation and recursive branch reads" },
|
|
284
|
-
{ name: "prism_session_append_idempotency_unique", table: "prism_session_append_idempotency", columns: ["session_id", "expected_parent_id", "idempotency_key"],
|
|
286
|
+
{ name: "prism_session_append_idempotency_unique", table: "prism_session_append_idempotency", columns: ["session_id", "expected_parent_id", "idempotency_key"], purpose: "append retry deduplication (primary key enforces uniqueness)" },
|
|
285
287
|
{ name: "prism_runs_session_started_idx", table: "prism_runs", columns: ["session_id", "started_at", "id"], purpose: "run history pagination" },
|
|
286
288
|
{ name: "prism_runs_branch_started_idx", table: "prism_runs", columns: ["branch_id", "started_at", "id"], purpose: "branch-scoped runs" },
|
|
287
289
|
{ name: "prism_runs_tenant_idempotency_unique", table: "prism_runs", columns: ["tenant_id", "idempotency_key"], unique: true, purpose: "run-level idempotency deduplication per tenant" },
|
|
@@ -296,19 +298,40 @@ export function createPersistenceSchemaModel() {
|
|
|
296
298
|
{ name: "prism_run_feedback_run_created_idx", table: "prism_run_feedback", columns: ["run_id", "created_at", "id"], purpose: "run feedback lookup" },
|
|
297
299
|
{ name: "prism_run_feedback_trace_created_idx", table: "prism_run_feedback", columns: ["trace_id", "created_at", "id"], purpose: "trace feedback lookup" },
|
|
298
300
|
{ name: "prism_agent_definitions_name_version_idx", table: "prism_agent_definitions", columns: ["name", "version"], purpose: "definition lookup" },
|
|
299
|
-
{ name: "prism_migrations_name_version_idx", table: "prism_migrations", columns: ["name", "version"],
|
|
301
|
+
{ name: "prism_migrations_name_version_idx", table: "prism_migrations", columns: ["name", "version"], purpose: "applied-migration lookup (table constraint enforces uniqueness)" },
|
|
300
302
|
],
|
|
301
303
|
};
|
|
302
304
|
}
|
|
305
|
+
function migrationStep(version, name, description) {
|
|
306
|
+
const model = createPersistenceSchemaModel();
|
|
307
|
+
const content = version === 1
|
|
308
|
+
? {
|
|
309
|
+
tables: model.tables
|
|
310
|
+
.filter((table) => table.name !== "prism_run_feedback")
|
|
311
|
+
.map((table) => table.name === "prism_usage"
|
|
312
|
+
? { ...table, columns: table.columns.filter((column) => !["scope", "turn", "attempt"].includes(column.name)) }
|
|
313
|
+
: table),
|
|
314
|
+
indexes: model.indexes.filter((index) => !index.name.startsWith("prism_usage_session_scope_") && !index.name.startsWith("prism_run_feedback_")),
|
|
315
|
+
}
|
|
316
|
+
: version === 2
|
|
317
|
+
? { table: "prism_usage", columns: ["scope", "turn", "attempt"], indexes: ["prism_usage_session_scope_recorded_idx"] }
|
|
318
|
+
: { tables: ["prism_run_feedback"], indexes: model.indexes.filter((index) => index.name.startsWith("prism_run_feedback_")).map((index) => index.name) };
|
|
319
|
+
return {
|
|
320
|
+
version,
|
|
321
|
+
name,
|
|
322
|
+
description,
|
|
323
|
+
checksum: createHash("sha256").update(JSON.stringify({ version, name, content })).digest("hex"),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
303
326
|
/** Canonical migration contract for production adapters. */
|
|
304
327
|
export function createPersistenceMigrationContract() {
|
|
305
328
|
return {
|
|
306
329
|
targetSchemaVersion: PERSISTENCE_SCHEMA_VERSION,
|
|
307
330
|
appliedMigrationsTable: "prism_migrations",
|
|
308
331
|
steps: [
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
332
|
+
migrationStep(1, "001_init", "Create core session, branch, entry, idempotency, run, ledger, and migration tables."),
|
|
333
|
+
migrationStep(2, "002_usage_scope", "Distinguish provider-turn usage from aggregate run totals."),
|
|
334
|
+
migrationStep(3, "003_run_feedback", "Add immutable ownership-scoped run/trace feedback and evaluation links."),
|
|
312
335
|
],
|
|
313
336
|
lockGuidance: "Acquire a dialect-specific migration lock before applying steps (PostgreSQL advisory lock; SQLite exclusive transaction). Only one process should migrate at a time.",
|
|
314
337
|
leastPrivilegeGuidance: "Run migrations with a DDL-capable role; use a separate least-privilege runtime role limited to INSERT/SELECT/UPDATE on adapter tables. Never grant migration credentials to the agent runtime.",
|
|
@@ -387,6 +410,8 @@ export function assertPersistenceMigrationContract(contract) {
|
|
|
387
410
|
throw new Error(`Migration steps must be strictly increasing; ${step.name} is out of order`);
|
|
388
411
|
if (names.has(step.name))
|
|
389
412
|
throw new Error(`Duplicate migration step name: ${step.name}`);
|
|
413
|
+
if (!/^[a-f0-9]{64}$/.test(step.checksum))
|
|
414
|
+
throw new Error(`Migration step ${step.name} must have a SHA-256 checksum`);
|
|
390
415
|
names.add(step.name);
|
|
391
416
|
previous = step.version;
|
|
392
417
|
}
|
|
@@ -394,6 +419,101 @@ export function assertPersistenceMigrationContract(contract) {
|
|
|
394
419
|
throw new Error(`Last migration step version ${previous} must equal targetSchemaVersion ${contract.targetSchemaVersion}`);
|
|
395
420
|
}
|
|
396
421
|
}
|
|
422
|
+
function schemaKey(columns) {
|
|
423
|
+
return columns.join("\u0000");
|
|
424
|
+
}
|
|
425
|
+
function normalizedDefault(value) {
|
|
426
|
+
return value?.trim().toLowerCase().replace(/::[a-z_ ]+$/, "");
|
|
427
|
+
}
|
|
428
|
+
function compatibleColumnType(dialect, actual, expected) {
|
|
429
|
+
const type = actual.trim().toUpperCase();
|
|
430
|
+
if (dialect === "sqlite") {
|
|
431
|
+
if (expected === "integer" || expected === "boolean")
|
|
432
|
+
return type === "INTEGER";
|
|
433
|
+
if (expected === "number")
|
|
434
|
+
return type === "REAL";
|
|
435
|
+
return type === "TEXT";
|
|
436
|
+
}
|
|
437
|
+
if (expected === "integer")
|
|
438
|
+
return type === "INTEGER";
|
|
439
|
+
if (expected === "boolean")
|
|
440
|
+
return type === "BOOLEAN";
|
|
441
|
+
if (expected === "number")
|
|
442
|
+
return type === "DOUBLE PRECISION";
|
|
443
|
+
return type === "TEXT";
|
|
444
|
+
}
|
|
445
|
+
/** Compare bounded dialect catalog output against every required schema-v3 detail. */
|
|
446
|
+
export function assertPersistenceSchemaShape(shape, dialect, model = createPersistenceSchemaModel()) {
|
|
447
|
+
assertPersistenceSchemaModel(model);
|
|
448
|
+
const actualTables = new Map(shape.tables.map((table) => [table.name, table]));
|
|
449
|
+
for (const expected of model.tables) {
|
|
450
|
+
const actual = actualTables.get(expected.name);
|
|
451
|
+
if (!actual)
|
|
452
|
+
throw new Error(`Persistence schema missing table ${expected.name}`);
|
|
453
|
+
if (actual.columns.length !== expected.columns.length)
|
|
454
|
+
throw new Error(`Persistence schema table ${expected.name} has unexpected columns`);
|
|
455
|
+
const actualColumns = new Map(actual.columns.map((column) => [column.name, column]));
|
|
456
|
+
for (const column of expected.columns) {
|
|
457
|
+
const found = actualColumns.get(column.name);
|
|
458
|
+
if (!found)
|
|
459
|
+
throw new Error(`Persistence schema table ${expected.name} missing column ${column.name}`);
|
|
460
|
+
if (!compatibleColumnType(dialect, found.type, column.type)) {
|
|
461
|
+
throw new Error(`Persistence schema column ${expected.name}.${column.name} has incompatible type`);
|
|
462
|
+
}
|
|
463
|
+
if (found.nullable !== (column.nullable === true)) {
|
|
464
|
+
throw new Error(`Persistence schema column ${expected.name}.${column.name} has incompatible nullability`);
|
|
465
|
+
}
|
|
466
|
+
if (normalizedDefault(found.defaultValue) !== normalizedDefault(column.defaultValue)) {
|
|
467
|
+
throw new Error(`Persistence schema column ${expected.name}.${column.name} has incompatible default`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (schemaKey(actual.primaryKey) !== schemaKey(expected.primaryKey)) {
|
|
471
|
+
throw new Error(`Persistence schema table ${expected.name} has incompatible primary key`);
|
|
472
|
+
}
|
|
473
|
+
const unique = new Set(actual.uniqueKeys.map(schemaKey));
|
|
474
|
+
for (const key of expected.uniqueKeys ?? []) {
|
|
475
|
+
if (!unique.has(schemaKey(key)) && schemaKey(actual.primaryKey) !== schemaKey(key)) {
|
|
476
|
+
throw new Error(`Persistence schema table ${expected.name} missing unique key (${key.join(", ")})`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const foreign = new Set(actual.foreignKeys.map((key) => `${schemaKey(key.columns)}>${key.referencesTable}:${schemaKey(key.referencesColumns)}`));
|
|
480
|
+
for (const key of expected.foreignKeys ?? []) {
|
|
481
|
+
if (!foreign.has(`${schemaKey(key.columns)}>${key.referencesTable}:${schemaKey(key.referencesColumns)}`)) {
|
|
482
|
+
throw new Error(`Persistence schema table ${expected.name} missing foreign key (${key.columns.join(", ")})`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const actualIndexes = new Map(shape.indexes.map((index) => [index.name, index]));
|
|
487
|
+
for (const expected of model.indexes) {
|
|
488
|
+
const actual = actualIndexes.get(expected.name);
|
|
489
|
+
if (!actual)
|
|
490
|
+
throw new Error(`Persistence schema missing required index ${expected.name}`);
|
|
491
|
+
if (actual.table !== expected.table || schemaKey(actual.columns) !== schemaKey(expected.columns) || actual.unique !== (expected.unique === true)) {
|
|
492
|
+
throw new Error(`Persistence schema index ${expected.name} has incompatible definition`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/** Reject altered migration history before any new DDL or runtime write. */
|
|
497
|
+
export function assertAppliedPersistenceMigrations(contract, applied) {
|
|
498
|
+
assertPersistenceMigrationContract(contract);
|
|
499
|
+
if (applied.length > contract.steps.length)
|
|
500
|
+
throw new Error("Migration history has unknown rows");
|
|
501
|
+
const legacyChecksums = applied.some((row) => row.checksum === null);
|
|
502
|
+
if (legacyChecksums && (applied.length !== contract.steps.length || !applied.every((row) => row.checksum === null))) {
|
|
503
|
+
throw new Error("Migration history has incomplete legacy checksums");
|
|
504
|
+
}
|
|
505
|
+
for (let index = 0; index < applied.length; index++) {
|
|
506
|
+
const row = applied[index];
|
|
507
|
+
const expected = contract.steps[index];
|
|
508
|
+
if (row.name !== expected.name || row.version !== String(expected.version)) {
|
|
509
|
+
throw new Error(`Migration history row ${index} does not match ${expected.name}`);
|
|
510
|
+
}
|
|
511
|
+
if (!legacyChecksums && row.checksum !== expected.checksum) {
|
|
512
|
+
throw new Error(`Migration history checksum mismatch for ${expected.name}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return { legacyChecksums };
|
|
516
|
+
}
|
|
397
517
|
/**
|
|
398
518
|
* Assert a dialect-local adapter exposes the canonical table and index names.
|
|
399
519
|
* Adapters pass the table/index names their migration runner created.
|
|
@@ -423,23 +543,17 @@ export function assertParameterizedQuery(sql, boundValues) {
|
|
|
423
543
|
}
|
|
424
544
|
/** Simulate migration up + reopen: applied steps must match the contract in order. */
|
|
425
545
|
export function assertMigrationUpAndReopen(contract, appliedAfterUp, appliedAfterReopen) {
|
|
426
|
-
|
|
427
|
-
if (appliedAfterUp.length !== contract.steps.length) {
|
|
428
|
-
throw new Error("Migration up did not apply every contract step");
|
|
429
|
-
}
|
|
430
|
-
for (let i = 0; i < contract.steps.length; i++) {
|
|
431
|
-
const step = contract.steps[i];
|
|
432
|
-
const row = appliedAfterUp[i];
|
|
433
|
-
if (row.name !== step.name || row.version !== String(step.version)) {
|
|
434
|
-
throw new Error(`Applied migration row ${i} does not match contract step ${step.name}`);
|
|
435
|
-
}
|
|
546
|
+
const first = assertAppliedPersistenceMigrations(contract, appliedAfterUp);
|
|
547
|
+
if (appliedAfterUp.length !== contract.steps.length || first.legacyChecksums) {
|
|
548
|
+
throw new Error("Migration up did not apply every checksummed contract step");
|
|
436
549
|
}
|
|
437
|
-
|
|
438
|
-
|
|
550
|
+
const second = assertAppliedPersistenceMigrations(contract, appliedAfterReopen);
|
|
551
|
+
if (second.legacyChecksums || appliedAfterReopen.length !== appliedAfterUp.length) {
|
|
552
|
+
throw new Error("Reopened adapter migration history diverged");
|
|
439
553
|
}
|
|
440
|
-
for (let
|
|
441
|
-
if (appliedAfterReopen[
|
|
442
|
-
throw new Error("Reopened adapter migration
|
|
554
|
+
for (let index = 0; index < appliedAfterUp.length; index++) {
|
|
555
|
+
if (appliedAfterReopen[index].checksum !== appliedAfterUp[index].checksum) {
|
|
556
|
+
throw new Error("Reopened adapter migration checksums diverged");
|
|
443
557
|
}
|
|
444
558
|
}
|
|
445
559
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { JsonObject, ModelConfig, ProviderRequestOptions } from "./contracts.js";
|
|
2
|
+
/**
|
|
3
|
+
* Portable thinking / reasoning effort levels shared across first-party providers.
|
|
4
|
+
* Model-dependent legality (which values a given model accepts) stays provider-owned.
|
|
5
|
+
*/
|
|
6
|
+
export declare const THINKING_LEVELS: readonly ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
7
|
+
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
8
|
+
/**
|
|
9
|
+
* Compat mapping families used by ≥2 packages, or explicit no-op for host-owned adapters.
|
|
10
|
+
* Provider packages keep unique escape hatches (budgets, keep/all, tool_stream) local.
|
|
11
|
+
*/
|
|
12
|
+
export type ThinkingCompatFamily = "openai_reasoning" | "reasoning_effort" | "thinking_type" | "noop";
|
|
13
|
+
export declare function isThinkingLevel(value: unknown): value is ThinkingLevel;
|
|
14
|
+
/**
|
|
15
|
+
* Normalize a host thinkingLevel string. Known levels are lowercased; other non-empty
|
|
16
|
+
* strings pass through as opaque effort values for forward-compatible provider fields.
|
|
17
|
+
*/
|
|
18
|
+
export declare function normalizeThinkingLevel(level: string): ThinkingLevel | string | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Build the `ProviderRequestOptions.compat` patch for a shared thinking level.
|
|
21
|
+
* Does not invent a second options tree — providers keep reading official fields from `compat`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function thinkingCompatFor(family: ThinkingCompatFamily, level: ThinkingLevel | string): JsonObject;
|
|
24
|
+
/**
|
|
25
|
+
* Merge a shared thinking level into `providerOptions.compat` for the given family.
|
|
26
|
+
* Per-turn patches win over prior compat via {@link mergeProviderRequestOptions}.
|
|
27
|
+
*/
|
|
28
|
+
export declare function applyThinkingLevel(options: ProviderRequestOptions | undefined, level: ThinkingLevel | string, family?: ThinkingCompatFamily): ProviderRequestOptions;
|
|
29
|
+
/**
|
|
30
|
+
* Best-effort family inference from model metadata without a second options tree.
|
|
31
|
+
* Prefer an explicit family in hosts/use-case workers when the provider is known.
|
|
32
|
+
*
|
|
33
|
+
* Heuristics (ordered):
|
|
34
|
+
* 1. Existing `compat.thinking` object → `thinking_type`
|
|
35
|
+
* 2. Existing `compat.reasoning` → `openai_reasoning`
|
|
36
|
+
* 3. Existing `compat.reasoning_effort` → `reasoning_effort`
|
|
37
|
+
* 4. Provider id starting with `openai` → `openai_reasoning`
|
|
38
|
+
* 5. Provider id `neuralwatt` → `reasoning_effort`
|
|
39
|
+
* 6. `capabilities.reasoning` → `reasoning_effort` (portable string field)
|
|
40
|
+
* 7. Else `noop`
|
|
41
|
+
*/
|
|
42
|
+
export declare function thinkingFamilyForModel(model: Pick<ModelConfig, "provider" | "compat" | "capabilities">): ThinkingCompatFamily;
|