@arnilo/prism 0.0.23 → 0.0.25
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 -3
- package/dist/agent-event-source.d.ts +11 -0
- package/dist/agent-event-source.js +512 -0
- package/dist/agent-loops.js +37 -5
- package/dist/agent-run-state.d.ts +27 -1
- package/dist/agent-run-state.js +86 -5
- package/dist/agents.js +890 -78
- package/dist/contracts.d.ts +338 -4
- package/dist/contracts.js +53 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +5 -2
- package/dist/testing/agent-event-source-conformance.d.ts +4 -0
- package/dist/testing/agent-event-source-conformance.js +54 -0
- package/dist/testing/persistence-schema.d.ts +2 -2
- package/dist/testing/persistence-schema.js +58 -21
- package/dist/testing/tool-effect-store-conformance.d.ts +9 -0
- package/dist/testing/tool-effect-store-conformance.js +85 -0
- package/dist/tool-effects.d.ts +15 -0
- package/dist/tool-effects.js +338 -0
- package/dist/tools.d.ts +4 -1
- package/dist/tools.js +219 -9
- package/docs/0.1.0-readiness.md +10 -9
- package/docs/a2a.md +6 -2
- package/docs/ag-ui-adoption.md +77 -0
- package/docs/ag-ui.md +77 -42
- package/docs/agent-events.md +5 -1
- package/docs/agent-loops.md +9 -1
- package/docs/agent-session-runtime.md +9 -2
- package/docs/browser-automation.md +2 -0
- package/docs/coding-agent-tools.md +2 -0
- package/docs/coding-security.md +1 -1
- package/docs/database-persistence.md +2 -0
- package/docs/enterprise-postgres-state.md +5 -1
- package/docs/host-security.md +8 -1
- package/docs/index.md +13 -11
- package/docs/mcp-tools.md +19 -2
- package/docs/migration.md +46 -0
- package/docs/performance.md +25 -0
- package/docs/postgres-persistence.md +5 -2
- package/docs/public-contracts.md +2 -0
- package/docs/release-and-install.md +70 -690
- package/docs/server.md +10 -6
- package/docs/sqlite-persistence.md +10 -2
- package/docs/supervisors.md +6 -0
- package/docs/tool-effects.md +95 -0
- package/docs/tools.md +4 -0
- package/docs/work-tools.md +4 -0
- package/docs/workflows.md +1 -1
- package/package.json +11 -3
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// ponytail: runner-free durable-event contract probe for database adapters.
|
|
2
|
+
/** Assert durable append/page/replay ownership and cursor behavior without a database dependency. */
|
|
3
|
+
export async function assertAgentEventSourceConforms(factory) {
|
|
4
|
+
const source = await factory();
|
|
5
|
+
const ownership = { tenantId: "tenant-a", accountId: "account-a", userId: "user-a" };
|
|
6
|
+
const input = { ownership, sessionId: "session-a", runId: "run-a" };
|
|
7
|
+
const first = event("event-a", "agent_started", input);
|
|
8
|
+
const second = event("event-b", "turn_started", input, "2026-01-01T00:00:01.000Z");
|
|
9
|
+
const storedFirst = await source.append(first);
|
|
10
|
+
const storedSecond = await source.append(second);
|
|
11
|
+
equal(storedFirst.sequence, 1, "first durable event must receive sequence 1");
|
|
12
|
+
equal(storedSecond.sequence, 2, "durable sequence must increase per run");
|
|
13
|
+
equal((await source.append(first)).sequence, 1, "identical duplicate append must be idempotent");
|
|
14
|
+
await rejects(() => source.append({ ...first, timestamp: "2026-01-01T00:00:02.000Z" }), "changed duplicate append must fail");
|
|
15
|
+
const page = await source.page({ ...input, limit: 1 });
|
|
16
|
+
equal(page.items.length, 1, "page limit must be honored");
|
|
17
|
+
equal(page.items[0].record.id, first.id, "page order must follow sequence");
|
|
18
|
+
if (!page.nextCursor)
|
|
19
|
+
throw new Error("truncated page must include a cursor");
|
|
20
|
+
const secondPage = await source.page({ ...input, after: page.nextCursor, limit: 1 });
|
|
21
|
+
equal(secondPage.items[0]?.record.id, second.id, "cursor must be exclusive");
|
|
22
|
+
const iterator = source.subscribe({ ...input, after: secondPage.items[0].cursor })[Symbol.asyncIterator]();
|
|
23
|
+
const pending = iterator.next();
|
|
24
|
+
const third = await source.append(event("event-c", "turn_started", input, "2026-01-01T00:00:02.000Z"));
|
|
25
|
+
equal((await pending).value?.record.id, third.id, "replay/live handoff dropped an event");
|
|
26
|
+
await iterator.return?.();
|
|
27
|
+
const terminal = await source.append(event("event-d", "agent_finished", input, "2026-01-01T00:00:03.000Z"));
|
|
28
|
+
const final = await source.page({ ...input, after: secondPage.items[0].cursor, limit: 10 });
|
|
29
|
+
equal(final.items.at(-1)?.record.id, terminal.id, "terminal page must include its terminal event");
|
|
30
|
+
equal(final.terminal, true, "terminal event must close only after prior events are delivered");
|
|
31
|
+
await rejects(() => source.page({ ...input, ownership: { ...ownership, tenantId: "tenant-b" }, after: page.nextCursor }), "foreign cursor must fail closed");
|
|
32
|
+
await rejects(() => source.append({ ...event("event-unredacted", "turn_started", input), redacted: false }), "unredacted append must fail");
|
|
33
|
+
await rejects(() => source.page({ ...input, limit: 0 }), "invalid page limit must fail");
|
|
34
|
+
}
|
|
35
|
+
function event(id, type, input, timestamp = "2026-01-01T00:00:00.000Z") {
|
|
36
|
+
const event = type === "turn_started"
|
|
37
|
+
? { type, sessionId: input.sessionId, runId: input.runId, turn: 1 }
|
|
38
|
+
: { type, sessionId: input.sessionId, runId: input.runId };
|
|
39
|
+
return { id, ...input.ownership, sessionId: input.sessionId, runId: input.runId, type, timestamp, event, redacted: true };
|
|
40
|
+
}
|
|
41
|
+
function equal(actual, expected, message) {
|
|
42
|
+
if (actual !== expected)
|
|
43
|
+
throw new Error(`${message}; expected ${String(expected)}, received ${String(actual)}`);
|
|
44
|
+
}
|
|
45
|
+
async function rejects(action, message) {
|
|
46
|
+
try {
|
|
47
|
+
await action();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
throw new Error(message);
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=agent-event-source-conformance.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { PersistencePage, SessionEntry, SessionEntryQuery } from "../contracts.js";
|
|
2
2
|
/** Current shared persistence schema version for production database adapters. */
|
|
3
|
-
export declare const PERSISTENCE_SCHEMA_VERSION =
|
|
4
|
-
export type PersistenceTableName = "prism_tenants" | "prism_accounts" | "prism_users" | "prism_agent_definitions" | "prism_sessions" | "prism_branches" | "prism_session_entries" | "prism_session_append_idempotency" | "prism_runs" | "prism_agent_events" | "prism_tool_calls" | "prism_usage" | "prism_run_feedback" | "prism_retention_policies" | "prism_legal_holds" | "prism_tenant_quotas" | "prism_migrations";
|
|
3
|
+
export declare const PERSISTENCE_SCHEMA_VERSION = 7;
|
|
4
|
+
export type PersistenceTableName = "prism_tenants" | "prism_accounts" | "prism_users" | "prism_agent_definitions" | "prism_sessions" | "prism_branches" | "prism_session_entries" | "prism_session_append_idempotency" | "prism_runs" | "prism_agent_events" | "prism_agent_event_streams" | "prism_tool_calls" | "prism_usage" | "prism_run_feedback" | "prism_retention_policies" | "prism_legal_holds" | "prism_tenant_quotas" | "prism_migrations";
|
|
5
5
|
export type PersistenceColumnType = "text" | "integer" | "number" | "boolean" | "json" | "timestamp";
|
|
6
6
|
export interface PersistenceColumnDefinition {
|
|
7
7
|
readonly name: string;
|
|
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
// this module defines the shared table/index/pagination/migration expectations
|
|
5
5
|
// adapter authors implement and test against before shipping dialect-specific DDL.
|
|
6
6
|
/** Current shared persistence schema version for production database adapters. */
|
|
7
|
-
export const PERSISTENCE_SCHEMA_VERSION =
|
|
7
|
+
export const PERSISTENCE_SCHEMA_VERSION = 7;
|
|
8
8
|
/** Guidance adapters must follow: values are bound parameters, never interpolated. */
|
|
9
9
|
export const PARAMETERIZED_QUERY_GUIDANCE = "Bind every user-supplied value (session ids, idempotency keys, tenant ids, timestamps, JSON payloads) as a query parameter. Quote/validate schema and table identifiers only; never interpolate untrusted strings into SQL text.";
|
|
10
10
|
const TENANT_COLUMNS = [
|
|
@@ -177,6 +177,17 @@ export function createPersistenceSchemaModel() {
|
|
|
177
177
|
],
|
|
178
178
|
foreignKeys: [{ columns: ["session_id"], referencesTable: "prism_sessions", referencesColumns: ["id"] }],
|
|
179
179
|
},
|
|
180
|
+
{
|
|
181
|
+
name: "prism_agent_event_streams",
|
|
182
|
+
primaryKey: ["session_id", "run_id"],
|
|
183
|
+
columns: [
|
|
184
|
+
{ name: "session_id", type: "text" },
|
|
185
|
+
{ name: "run_id", type: "text" },
|
|
186
|
+
{ name: "next_sequence", type: "integer" },
|
|
187
|
+
{ name: "updated_at", type: "timestamp" },
|
|
188
|
+
],
|
|
189
|
+
foreignKeys: [{ columns: ["session_id"], referencesTable: "prism_sessions", referencesColumns: ["id"] }],
|
|
190
|
+
},
|
|
180
191
|
{
|
|
181
192
|
name: "prism_tool_calls",
|
|
182
193
|
primaryKey: ["id"],
|
|
@@ -369,6 +380,7 @@ export function createPersistenceSchemaModel() {
|
|
|
369
380
|
name: "prism_agent_events_run_sequence_idx",
|
|
370
381
|
table: "prism_agent_events",
|
|
371
382
|
columns: ["run_id", "sequence"],
|
|
383
|
+
unique: true,
|
|
372
384
|
purpose: "stable per-run event timeline pagination",
|
|
373
385
|
},
|
|
374
386
|
{
|
|
@@ -377,6 +389,12 @@ export function createPersistenceSchemaModel() {
|
|
|
377
389
|
columns: ["session_id", "timestamp", "id"],
|
|
378
390
|
purpose: "event stream pagination",
|
|
379
391
|
},
|
|
392
|
+
{
|
|
393
|
+
name: "prism_agent_events_owner_timestamp_sequence_idx",
|
|
394
|
+
table: "prism_agent_events",
|
|
395
|
+
columns: ["tenant_id", "account_id", "user_id", "timestamp", "sequence", "id"],
|
|
396
|
+
purpose: "owned durable event retention cleanup",
|
|
397
|
+
},
|
|
380
398
|
{
|
|
381
399
|
name: "prism_tool_calls_session_name_started_idx",
|
|
382
400
|
table: "prism_tool_calls",
|
|
@@ -463,34 +481,51 @@ function migrationStep(version, name, description) {
|
|
|
463
481
|
const content = version === 1
|
|
464
482
|
? {
|
|
465
483
|
tables: model.tables
|
|
466
|
-
.filter((table) => table.name !== "prism_run_feedback")
|
|
484
|
+
.filter((table) => table.name !== "prism_run_feedback" && table.name !== "prism_agent_event_streams")
|
|
467
485
|
.map((table) => table.name === "prism_usage"
|
|
468
486
|
? { ...table, columns: table.columns.filter((column) => !["scope", "turn", "attempt"].includes(column.name)) }
|
|
469
487
|
: table),
|
|
470
|
-
indexes: model.indexes
|
|
488
|
+
indexes: model.indexes
|
|
489
|
+
.filter((index) => !index.name.startsWith("prism_usage_session_scope_") &&
|
|
490
|
+
!index.name.startsWith("prism_run_feedback_") &&
|
|
491
|
+
!index.name.startsWith("prism_agent_event_streams_") &&
|
|
492
|
+
!index.name.startsWith("prism_agent_events_owner_timestamp_"))
|
|
493
|
+
.map((index) => index.name === "prism_agent_events_run_sequence_idx"
|
|
494
|
+
? (() => {
|
|
495
|
+
const { unique: _unique, ...legacy } = index;
|
|
496
|
+
return legacy;
|
|
497
|
+
})()
|
|
498
|
+
: index),
|
|
471
499
|
}
|
|
472
|
-
: version ===
|
|
473
|
-
? {
|
|
474
|
-
: version ===
|
|
500
|
+
: version === 7
|
|
501
|
+
? { indexes: ["prism_agent_events_owner_timestamp_sequence_idx"] }
|
|
502
|
+
: version === 6
|
|
475
503
|
? {
|
|
476
|
-
tables: ["
|
|
477
|
-
indexes:
|
|
504
|
+
tables: ["prism_agent_event_streams"],
|
|
505
|
+
indexes: ["prism_agent_events_run_sequence_idx"],
|
|
478
506
|
}
|
|
479
|
-
: version ===
|
|
480
|
-
?
|
|
481
|
-
|
|
482
|
-
: version === 5
|
|
507
|
+
: version === 2
|
|
508
|
+
? { table: "prism_usage", columns: ["scope", "turn", "attempt"], indexes: ["prism_usage_session_scope_recorded_idx"] }
|
|
509
|
+
: version === 3
|
|
483
510
|
? {
|
|
484
|
-
tables: ["
|
|
485
|
-
indexes:
|
|
486
|
-
"prism_legal_holds_owner_resource_idx",
|
|
487
|
-
"prism_legal_holds_created_id_idx",
|
|
488
|
-
"prism_tenant_quotas_owner_kind_idx",
|
|
489
|
-
],
|
|
511
|
+
tables: ["prism_run_feedback"],
|
|
512
|
+
indexes: model.indexes.filter((index) => index.name.startsWith("prism_run_feedback_")).map((index) => index.name),
|
|
490
513
|
}
|
|
491
|
-
:
|
|
492
|
-
|
|
493
|
-
|
|
514
|
+
: version === 4
|
|
515
|
+
? // Adapter-local FTS objects (SQLite FTS5 / Postgres tsvector) map to this canonical name.
|
|
516
|
+
{ search: ["prism_session_search"], indexes: ["prism_sessions_updated_id_idx"] }
|
|
517
|
+
: version === 5
|
|
518
|
+
? {
|
|
519
|
+
tables: ["prism_legal_holds", "prism_tenant_quotas"],
|
|
520
|
+
indexes: [
|
|
521
|
+
"prism_legal_holds_owner_resource_idx",
|
|
522
|
+
"prism_legal_holds_created_id_idx",
|
|
523
|
+
"prism_tenant_quotas_owner_kind_idx",
|
|
524
|
+
],
|
|
525
|
+
}
|
|
526
|
+
: (() => {
|
|
527
|
+
throw new Error(`Unknown migration version ${version}`);
|
|
528
|
+
})();
|
|
494
529
|
return {
|
|
495
530
|
version,
|
|
496
531
|
name,
|
|
@@ -509,6 +544,8 @@ export function createPersistenceMigrationContract() {
|
|
|
509
544
|
migrationStep(3, "003_run_feedback", "Add immutable ownership-scoped run/trace feedback and evaluation links."),
|
|
510
545
|
migrationStep(4, "004_session_search", "Add bounded session search indexes and adapter-local FTS objects."),
|
|
511
546
|
migrationStep(5, "005_lifecycle_hold_quota", "Add legal-hold and tenant-quota tables for retention lifecycle."),
|
|
547
|
+
migrationStep(6, "006_agent_event_source", "Add transactional per-run event counters and unique durable event sequencing."),
|
|
548
|
+
migrationStep(7, "007_agent_event_retention_index", "Add an exact-owner durable-event retention cleanup index."),
|
|
512
549
|
],
|
|
513
550
|
lockGuidance: "Acquire a dialect-specific migration lock before applying steps (PostgreSQL advisory lock; SQLite exclusive transaction). Only one process should migrate at a time.",
|
|
514
551
|
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.",
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AgentIdentity, ToolEffectKey, ToolEffectStore } from "../contracts.js";
|
|
2
|
+
export interface ToolEffectStoreConformanceOptions {
|
|
3
|
+
readonly identity?: AgentIdentity;
|
|
4
|
+
readonly ownership?: ToolEffectKey["ownership"];
|
|
5
|
+
readonly key?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Assert core claim/CAS, duplicate, reconciliation, and cleanup semantics without a test framework. */
|
|
8
|
+
export declare function assertToolEffectStoreConforms(factory: () => ToolEffectStore | Promise<ToolEffectStore>, options?: ToolEffectStoreConformanceOptions): Promise<void>;
|
|
9
|
+
export declare function runToolEffectStoreConformance(factory: () => ToolEffectStore | Promise<ToolEffectStore>, options?: ToolEffectStoreConformanceOptions): Promise<void>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Assert core claim/CAS, duplicate, reconciliation, and cleanup semantics without a test framework. */
|
|
2
|
+
export async function assertToolEffectStoreConforms(factory, options = {}) {
|
|
3
|
+
const store = await factory();
|
|
4
|
+
const identity = options.identity ?? testIdentity();
|
|
5
|
+
const ownership = options.ownership ?? { tenantId: identity.tenantId };
|
|
6
|
+
const base = key(identity, ownership, options.key ?? "prism:tool-effect:v1:conformance");
|
|
7
|
+
const first = await store.begin(base);
|
|
8
|
+
if (first.outcome !== "acquired" || first.record.status !== "pending" || !first.record.claimToken) {
|
|
9
|
+
throw new Error("store must acquire an absent tool effect as pending with a claim token");
|
|
10
|
+
}
|
|
11
|
+
const duplicate = await store.begin(base);
|
|
12
|
+
if (duplicate.outcome !== "existing" || duplicate.record.status !== "pending") {
|
|
13
|
+
throw new Error("store must return existing pending effects without a second claim");
|
|
14
|
+
}
|
|
15
|
+
const dispatched = await store.markDispatched(transition(base, first.record));
|
|
16
|
+
if (dispatched.status !== "dispatched" || dispatched.version !== first.record.version + 1) {
|
|
17
|
+
throw new Error("store must transition a claimed pending effect to dispatched");
|
|
18
|
+
}
|
|
19
|
+
const result = { toolCallId: base.toolCallId, name: base.toolName, value: { ok: true } };
|
|
20
|
+
const completed = await store.complete({ ...transition(base, dispatched), result });
|
|
21
|
+
if (completed.status !== "completed" || completed.result?.toolCallId !== base.toolCallId) {
|
|
22
|
+
throw new Error("store must retain a completed bounded result");
|
|
23
|
+
}
|
|
24
|
+
const replay = await store.begin(base);
|
|
25
|
+
if (replay.outcome !== "existing" || replay.record.status !== "completed") {
|
|
26
|
+
throw new Error("store must preserve completed duplicate state");
|
|
27
|
+
}
|
|
28
|
+
await expectReject(() => store.markDispatched(transition(base, dispatched)), "store must reject stale claim/version transitions");
|
|
29
|
+
const unknownKey = key(identity, ownership, `${base.key}:unknown`, "call-unknown");
|
|
30
|
+
const unknownPending = await store.begin(unknownKey);
|
|
31
|
+
const unknownDispatched = await store.markDispatched(transition(unknownKey, unknownPending.record));
|
|
32
|
+
const unknown = await store.markUnknown({ ...transition(unknownKey, unknownDispatched), failure: { code: "test" } });
|
|
33
|
+
if (unknown.status !== "unknown")
|
|
34
|
+
throw new Error("store must mark dispatched effects unknown");
|
|
35
|
+
const resolved = await store.resolveUnknown({
|
|
36
|
+
...unknownKey,
|
|
37
|
+
expectedVersion: unknown.version,
|
|
38
|
+
status: "failed_terminal",
|
|
39
|
+
failure: { code: "test" },
|
|
40
|
+
});
|
|
41
|
+
if (resolved.status !== "failed_terminal")
|
|
42
|
+
throw new Error("store must CAS-resolve unknown effects");
|
|
43
|
+
const cleanup = await store.cleanup({ ownership, before: new Date(Date.now() + 60_000).toISOString(), limit: 100 });
|
|
44
|
+
if (cleanup.deleted < 1)
|
|
45
|
+
throw new Error("store cleanup must remove terminal effects");
|
|
46
|
+
}
|
|
47
|
+
export async function runToolEffectStoreConformance(factory, options = {}) {
|
|
48
|
+
await assertToolEffectStoreConforms(factory, options);
|
|
49
|
+
}
|
|
50
|
+
function key(identity, ownership, value, toolCallId = "call") {
|
|
51
|
+
return {
|
|
52
|
+
identity,
|
|
53
|
+
ownership,
|
|
54
|
+
key: value,
|
|
55
|
+
sessionId: "session",
|
|
56
|
+
runId: "run",
|
|
57
|
+
toolCallId,
|
|
58
|
+
toolName: "effect.tool",
|
|
59
|
+
argumentsHash: "a".repeat(64),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function transition(base, record) {
|
|
63
|
+
if (!record.claimToken)
|
|
64
|
+
throw new Error("store transition record lacks claim token");
|
|
65
|
+
return { ...base, claimToken: record.claimToken, expectedVersion: record.version };
|
|
66
|
+
}
|
|
67
|
+
async function expectReject(run, message) {
|
|
68
|
+
try {
|
|
69
|
+
await run();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
throw new Error(message);
|
|
75
|
+
}
|
|
76
|
+
function testIdentity() {
|
|
77
|
+
return {
|
|
78
|
+
tenantId: "tenant",
|
|
79
|
+
principal: { kind: "service", id: "conformance" },
|
|
80
|
+
scopes: ["tools:execute"],
|
|
81
|
+
issuedAt: "2026-01-01T00:00:00.000Z",
|
|
82
|
+
verified: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=tool-effect-store-conformance.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { JsonObject, ToolEffectKey, ToolEffectStore } from "./contracts.js";
|
|
2
|
+
export type ToolEffectErrorCode = "ERR_PRISM_TOOL_EFFECT_REQUIRED" | "ERR_PRISM_TOOL_EFFECT_CONFLICT" | "ERR_PRISM_TOOL_EFFECT_UNKNOWN" | "ERR_PRISM_TOOL_EFFECT_COMPLETED" | "ERR_PRISM_TOOL_EFFECT_LIMIT";
|
|
3
|
+
export declare class ToolEffectError extends Error {
|
|
4
|
+
readonly code: ToolEffectErrorCode;
|
|
5
|
+
constructor(code: ToolEffectErrorCode, message: string);
|
|
6
|
+
}
|
|
7
|
+
/** Stable JSON representation for an already-validated tool arguments object. */
|
|
8
|
+
export declare function canonicalToolEffectJson(value: unknown): string;
|
|
9
|
+
export declare function toolEffectArgumentsHash(argumentsValue: JsonObject): string;
|
|
10
|
+
/** Derives the only core-authoritative key. Callers never supply this from model input. */
|
|
11
|
+
export declare function deriveToolEffectKey(input: Omit<ToolEffectKey, "key" | "signal">): string;
|
|
12
|
+
/** In-process reference. Use a durable adapter for cross-replica claims. */
|
|
13
|
+
export declare function createMemoryToolEffectStore(options?: {
|
|
14
|
+
readonly now?: () => number;
|
|
15
|
+
}): ToolEffectStore;
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { assertIdentityActive, assertIdentityMatchesOwnership } from "./identity.js";
|
|
3
|
+
export class ToolEffectError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = "ToolEffectError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const DEFAULT_CLAIM_TTL_MS = 15 * 60_000;
|
|
12
|
+
const HARD_CLAIM_TTL_MS = 60 * 60_000;
|
|
13
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
14
|
+
const HARD_MAX_ATTEMPTS = 10;
|
|
15
|
+
const DEFAULT_CLEANUP_LIMIT = 100;
|
|
16
|
+
const HARD_CLEANUP_LIMIT = 500;
|
|
17
|
+
const MAX_EFFECT_KEY_BYTES = 96;
|
|
18
|
+
const MAX_TOOL_NAME_BYTES = 512;
|
|
19
|
+
const MAX_IDENTIFIER_BYTES = 512;
|
|
20
|
+
const MAX_RESULT_BYTES = 64 * 1024;
|
|
21
|
+
const MAX_REFERENCE_BYTES = 1024;
|
|
22
|
+
const MAX_RECORD_BYTES = 128 * 1024;
|
|
23
|
+
/** Stable JSON representation for an already-validated tool arguments object. */
|
|
24
|
+
export function canonicalToolEffectJson(value) {
|
|
25
|
+
return JSON.stringify(canonical(value));
|
|
26
|
+
}
|
|
27
|
+
export function toolEffectArgumentsHash(argumentsValue) {
|
|
28
|
+
return createHash("sha256").update(canonicalToolEffectJson(argumentsValue)).digest("hex");
|
|
29
|
+
}
|
|
30
|
+
/** Derives the only core-authoritative key. Callers never supply this from model input. */
|
|
31
|
+
export function deriveToolEffectKey(input) {
|
|
32
|
+
const value = canonicalToolEffectJson({
|
|
33
|
+
tenantId: input.ownership.tenantId,
|
|
34
|
+
accountId: input.ownership.accountId ?? null,
|
|
35
|
+
userId: input.ownership.userId ?? null,
|
|
36
|
+
principalId: input.identity.principal.id,
|
|
37
|
+
sessionId: input.sessionId,
|
|
38
|
+
runId: input.runId,
|
|
39
|
+
toolCallId: input.toolCallId,
|
|
40
|
+
toolName: input.toolName,
|
|
41
|
+
argumentsHash: input.argumentsHash,
|
|
42
|
+
});
|
|
43
|
+
return `prism:tool-effect:v1:${createHash("sha256").update(value).digest("hex")}`;
|
|
44
|
+
}
|
|
45
|
+
/** In-process reference. Use a durable adapter for cross-replica claims. */
|
|
46
|
+
export function createMemoryToolEffectStore(options = {}) {
|
|
47
|
+
const records = new Map();
|
|
48
|
+
const now = options.now ?? Date.now;
|
|
49
|
+
function current(input) {
|
|
50
|
+
throwIfAborted(input.signal);
|
|
51
|
+
validateKey(input);
|
|
52
|
+
const found = records.get(recordKey(input));
|
|
53
|
+
if (!found)
|
|
54
|
+
return undefined;
|
|
55
|
+
assertMatches(found, input);
|
|
56
|
+
const expired = expire(found, now());
|
|
57
|
+
if (expired !== found)
|
|
58
|
+
records.set(recordKey(input), expired);
|
|
59
|
+
return expired;
|
|
60
|
+
}
|
|
61
|
+
function save(record, identity) {
|
|
62
|
+
const frozen = freezeRecord(record);
|
|
63
|
+
assertRecordSize(frozen);
|
|
64
|
+
records.set(recordKey({ identity, key: frozen.key }), frozen);
|
|
65
|
+
return frozen;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
async get(input) {
|
|
69
|
+
return current(input);
|
|
70
|
+
},
|
|
71
|
+
async begin(input) {
|
|
72
|
+
const existing = current(input);
|
|
73
|
+
const timestamp = now();
|
|
74
|
+
const ttl = claimTtl(input.claimTtlMs);
|
|
75
|
+
const attempts = maxAttempts(input.maxAttempts);
|
|
76
|
+
if (!existing)
|
|
77
|
+
return { outcome: "acquired", record: save(claim(input, 1, timestamp, ttl), input.identity) };
|
|
78
|
+
if (existing.status === "failed_retryable" && existing.attempt < attempts) {
|
|
79
|
+
return { outcome: "acquired", record: save(claim(input, existing.attempt + 1, timestamp, ttl, existing), input.identity) };
|
|
80
|
+
}
|
|
81
|
+
return { outcome: "existing", record: existing };
|
|
82
|
+
},
|
|
83
|
+
async markDispatched(input) {
|
|
84
|
+
const record = requireClaim(current(input), input, ["pending"]);
|
|
85
|
+
return save({ ...record, status: "dispatched", version: record.version + 1, updatedAt: timestamp(now()) }, input.identity);
|
|
86
|
+
},
|
|
87
|
+
async complete(input) {
|
|
88
|
+
const record = requireClaim(current(input), input, ["dispatched"]);
|
|
89
|
+
const result = input.result === undefined ? undefined : validateResult(input.result, input);
|
|
90
|
+
const resultRef = input.resultRef === undefined ? undefined : validateReference(input.resultRef);
|
|
91
|
+
return save({
|
|
92
|
+
...withoutClaim(record),
|
|
93
|
+
status: "completed",
|
|
94
|
+
version: record.version + 1,
|
|
95
|
+
...(result === undefined ? {} : { result }),
|
|
96
|
+
...(resultRef === undefined ? {} : { resultRef }),
|
|
97
|
+
updatedAt: timestamp(now()),
|
|
98
|
+
}, input.identity);
|
|
99
|
+
},
|
|
100
|
+
async fail(input) {
|
|
101
|
+
const record = requireClaim(current(input), input, ["pending", "dispatched"]);
|
|
102
|
+
return save({
|
|
103
|
+
...withoutClaim(record),
|
|
104
|
+
status: input.status,
|
|
105
|
+
version: record.version + 1,
|
|
106
|
+
failure: validateFailure(input.failure),
|
|
107
|
+
updatedAt: timestamp(now()),
|
|
108
|
+
}, input.identity);
|
|
109
|
+
},
|
|
110
|
+
async markUnknown(input) {
|
|
111
|
+
const record = requireClaim(current(input), input, ["dispatched"]);
|
|
112
|
+
return save({
|
|
113
|
+
...withoutClaim(record),
|
|
114
|
+
status: "unknown",
|
|
115
|
+
version: record.version + 1,
|
|
116
|
+
...(input.failure === undefined ? {} : { failure: validateFailure(input.failure) }),
|
|
117
|
+
updatedAt: timestamp(now()),
|
|
118
|
+
}, input.identity);
|
|
119
|
+
},
|
|
120
|
+
async resolveUnknown(input) {
|
|
121
|
+
const record = current(input);
|
|
122
|
+
if (!record || record.status !== "unknown" || record.version !== input.expectedVersion)
|
|
123
|
+
throw conflict();
|
|
124
|
+
const result = input.result === undefined ? undefined : validateResult(input.result, input);
|
|
125
|
+
const resultRef = input.resultRef === undefined ? undefined : validateReference(input.resultRef);
|
|
126
|
+
return save({
|
|
127
|
+
...record,
|
|
128
|
+
status: input.status,
|
|
129
|
+
version: record.version + 1,
|
|
130
|
+
...(result === undefined ? {} : { result }),
|
|
131
|
+
...(resultRef === undefined ? {} : { resultRef }),
|
|
132
|
+
...(input.failure === undefined ? {} : { failure: validateFailure(input.failure) }),
|
|
133
|
+
updatedAt: timestamp(now()),
|
|
134
|
+
}, input.identity);
|
|
135
|
+
},
|
|
136
|
+
async cleanup(input) {
|
|
137
|
+
throwIfAborted(input.signal);
|
|
138
|
+
const before = Date.parse(input.before);
|
|
139
|
+
if (!Number.isFinite(before))
|
|
140
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_LIMIT", "cleanup boundary is invalid");
|
|
141
|
+
validateOwnership(input.ownership);
|
|
142
|
+
const limit = cleanupLimit(input.limit);
|
|
143
|
+
let deleted = 0;
|
|
144
|
+
for (const [id, record] of records) {
|
|
145
|
+
throwIfAborted(input.signal);
|
|
146
|
+
if (deleted >= limit)
|
|
147
|
+
break;
|
|
148
|
+
if (!sameOwnership(record, input.ownership) || !isTerminal(record.status) || Date.parse(record.updatedAt) >= before)
|
|
149
|
+
continue;
|
|
150
|
+
records.delete(id);
|
|
151
|
+
deleted += 1;
|
|
152
|
+
}
|
|
153
|
+
return { deleted };
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function canonical(value) {
|
|
158
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
159
|
+
return value;
|
|
160
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
161
|
+
return value;
|
|
162
|
+
if (Array.isArray(value))
|
|
163
|
+
return value.map(canonical);
|
|
164
|
+
if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
|
|
165
|
+
const out = {};
|
|
166
|
+
for (const key of Object.keys(value).sort())
|
|
167
|
+
out[key] = canonical(value[key]);
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_LIMIT", "tool effect values must be JSON");
|
|
171
|
+
}
|
|
172
|
+
function claim(input, attempt, currentTime, ttl, previous) {
|
|
173
|
+
return {
|
|
174
|
+
...owner(input.ownership),
|
|
175
|
+
key: input.key,
|
|
176
|
+
sessionId: input.sessionId,
|
|
177
|
+
runId: input.runId,
|
|
178
|
+
toolCallId: input.toolCallId,
|
|
179
|
+
toolName: input.toolName,
|
|
180
|
+
argumentsHash: input.argumentsHash,
|
|
181
|
+
status: "pending",
|
|
182
|
+
attempt,
|
|
183
|
+
version: (previous?.version ?? 0) + 1,
|
|
184
|
+
claimToken: randomUUID(),
|
|
185
|
+
createdAt: previous?.createdAt ?? timestamp(currentTime),
|
|
186
|
+
updatedAt: timestamp(currentTime),
|
|
187
|
+
expiresAt: timestamp(currentTime + ttl),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function expire(record, currentTime) {
|
|
191
|
+
if ((record.status !== "pending" && record.status !== "dispatched") || !record.expiresAt || Date.parse(record.expiresAt) > currentTime)
|
|
192
|
+
return record;
|
|
193
|
+
const status = record.status === "pending" ? "failed_retryable" : "unknown";
|
|
194
|
+
return freezeRecord({
|
|
195
|
+
...withoutClaim(record),
|
|
196
|
+
status,
|
|
197
|
+
version: record.version + 1,
|
|
198
|
+
failure: { code: "ERR_PRISM_TOOL_EFFECT_EXPIRED" },
|
|
199
|
+
updatedAt: timestamp(currentTime),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function requireClaim(record, input, statuses) {
|
|
203
|
+
if (!record || !statuses.includes(record.status) || record.version !== input.expectedVersion || record.claimToken !== input.claimToken)
|
|
204
|
+
throw conflict();
|
|
205
|
+
return record;
|
|
206
|
+
}
|
|
207
|
+
function withoutClaim(record) {
|
|
208
|
+
const { claimToken: _claimToken, expiresAt: _expiresAt, ...rest } = record;
|
|
209
|
+
return rest;
|
|
210
|
+
}
|
|
211
|
+
function validateKey(input) {
|
|
212
|
+
assertIdentityActive(input.identity);
|
|
213
|
+
validateOwnership(input.ownership);
|
|
214
|
+
assertIdentityMatchesOwnership(input.identity, input.ownership);
|
|
215
|
+
if (input.ownership.tenantId !== input.identity.tenantId ||
|
|
216
|
+
input.ownership.accountId !== input.identity.accountId ||
|
|
217
|
+
input.ownership.userId !== input.identity.userId)
|
|
218
|
+
throw conflict();
|
|
219
|
+
validateText(input.key, MAX_EFFECT_KEY_BYTES, "effect key");
|
|
220
|
+
validateText(input.sessionId, MAX_IDENTIFIER_BYTES, "session id");
|
|
221
|
+
validateText(input.runId, MAX_IDENTIFIER_BYTES, "run id");
|
|
222
|
+
validateText(input.toolCallId, MAX_IDENTIFIER_BYTES, "tool call id");
|
|
223
|
+
validateText(input.toolName, MAX_TOOL_NAME_BYTES, "tool name");
|
|
224
|
+
if (!/^[a-f0-9]{64}$/.test(input.argumentsHash))
|
|
225
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_LIMIT", "arguments hash is invalid");
|
|
226
|
+
}
|
|
227
|
+
function validateOwnership(ownership) {
|
|
228
|
+
if (!ownership.tenantId?.trim())
|
|
229
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_CONFLICT", "tool effect ownership is required");
|
|
230
|
+
for (const value of [ownership.tenantId, ownership.accountId, ownership.userId]) {
|
|
231
|
+
if (value !== undefined)
|
|
232
|
+
validateText(value, MAX_IDENTIFIER_BYTES, "ownership");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function assertMatches(record, input) {
|
|
236
|
+
if (record.key !== input.key ||
|
|
237
|
+
record.sessionId !== input.sessionId ||
|
|
238
|
+
record.runId !== input.runId ||
|
|
239
|
+
record.toolCallId !== input.toolCallId ||
|
|
240
|
+
record.toolName !== input.toolName ||
|
|
241
|
+
record.argumentsHash !== input.argumentsHash ||
|
|
242
|
+
!sameOwnership(record, input.ownership)) {
|
|
243
|
+
throw conflict();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function validateResult(result, input) {
|
|
247
|
+
if (result.toolCallId !== input.toolCallId || result.name !== input.toolName)
|
|
248
|
+
throw conflict();
|
|
249
|
+
return jsonSnapshot(result, MAX_RESULT_BYTES, "tool result");
|
|
250
|
+
}
|
|
251
|
+
function validateReference(reference) {
|
|
252
|
+
validateText(reference, MAX_REFERENCE_BYTES, "effect reference");
|
|
253
|
+
return reference;
|
|
254
|
+
}
|
|
255
|
+
function validateFailure(failure) {
|
|
256
|
+
validateText(failure.code, 128, "effect failure code");
|
|
257
|
+
return Object.freeze({ ...failure, ...(failure.reference === undefined ? {} : { reference: validateReference(failure.reference) }) });
|
|
258
|
+
}
|
|
259
|
+
function assertRecordSize(record) {
|
|
260
|
+
void jsonSnapshot(record, MAX_RECORD_BYTES, "tool effect record");
|
|
261
|
+
}
|
|
262
|
+
function jsonSnapshot(value, maxBytes, label) {
|
|
263
|
+
let text;
|
|
264
|
+
try {
|
|
265
|
+
text = JSON.stringify(value);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_LIMIT", `${label} must be JSON serializable`);
|
|
269
|
+
}
|
|
270
|
+
if (text === undefined || Buffer.byteLength(text) > maxBytes)
|
|
271
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_LIMIT", `${label} exceeds limits`);
|
|
272
|
+
return JSON.parse(text);
|
|
273
|
+
}
|
|
274
|
+
function freezeRecord(record) {
|
|
275
|
+
return freezeJson(jsonSnapshot(record, MAX_RECORD_BYTES, "tool effect record"));
|
|
276
|
+
}
|
|
277
|
+
function freezeJson(value) {
|
|
278
|
+
if (!value || typeof value !== "object")
|
|
279
|
+
return value;
|
|
280
|
+
for (const child of Object.values(value))
|
|
281
|
+
freezeJson(child);
|
|
282
|
+
return Object.freeze(value);
|
|
283
|
+
}
|
|
284
|
+
function owner(ownership) {
|
|
285
|
+
return {
|
|
286
|
+
tenantId: ownership.tenantId,
|
|
287
|
+
...(ownership.accountId === undefined ? {} : { accountId: ownership.accountId }),
|
|
288
|
+
...(ownership.userId === undefined ? {} : { userId: ownership.userId }),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function sameOwnership(left, right) {
|
|
292
|
+
return left.tenantId === right.tenantId && left.accountId === right.accountId && left.userId === right.userId;
|
|
293
|
+
}
|
|
294
|
+
function recordKey(input) {
|
|
295
|
+
return JSON.stringify([
|
|
296
|
+
input.identity.tenantId,
|
|
297
|
+
input.identity.accountId ?? "",
|
|
298
|
+
input.identity.userId ?? "",
|
|
299
|
+
input.identity.principal.id,
|
|
300
|
+
input.key,
|
|
301
|
+
]);
|
|
302
|
+
}
|
|
303
|
+
function isTerminal(status) {
|
|
304
|
+
return status === "completed" || status === "failed_terminal";
|
|
305
|
+
}
|
|
306
|
+
function timestamp(value) {
|
|
307
|
+
return new Date(value).toISOString();
|
|
308
|
+
}
|
|
309
|
+
function claimTtl(value) {
|
|
310
|
+
const ttl = value ?? DEFAULT_CLAIM_TTL_MS;
|
|
311
|
+
if (!Number.isSafeInteger(ttl) || ttl < 1 || ttl > HARD_CLAIM_TTL_MS)
|
|
312
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_LIMIT", "claim TTL exceeds limits");
|
|
313
|
+
return ttl;
|
|
314
|
+
}
|
|
315
|
+
function maxAttempts(value) {
|
|
316
|
+
const attempts = value ?? DEFAULT_MAX_ATTEMPTS;
|
|
317
|
+
if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > HARD_MAX_ATTEMPTS)
|
|
318
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_LIMIT", "effect attempts exceed limits");
|
|
319
|
+
return attempts;
|
|
320
|
+
}
|
|
321
|
+
function cleanupLimit(value) {
|
|
322
|
+
const limit = value ?? DEFAULT_CLEANUP_LIMIT;
|
|
323
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > HARD_CLEANUP_LIMIT)
|
|
324
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_LIMIT", "cleanup limit exceeds limits");
|
|
325
|
+
return limit;
|
|
326
|
+
}
|
|
327
|
+
function validateText(value, maxBytes, label) {
|
|
328
|
+
if (typeof value !== "string" || !value.trim() || Buffer.byteLength(value) > maxBytes)
|
|
329
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_LIMIT", `${label} is required and bounded`);
|
|
330
|
+
}
|
|
331
|
+
function conflict() {
|
|
332
|
+
return new ToolEffectError("ERR_PRISM_TOOL_EFFECT_CONFLICT", "tool effect transition conflict");
|
|
333
|
+
}
|
|
334
|
+
function throwIfAborted(signal) {
|
|
335
|
+
if (signal?.aborted)
|
|
336
|
+
throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
337
|
+
}
|
|
338
|
+
//# sourceMappingURL=tool-effects.js.map
|