@arnilo/prism 0.0.23 → 0.0.24
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 +19 -3
- package/dist/agent-event-source.d.ts +11 -0
- package/dist/agent-event-source.js +512 -0
- package/dist/agent-run-state.js +6 -1
- package/dist/agents.js +7 -2
- package/dist/contracts.d.ts +157 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +4 -1
- 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 +3 -1
- package/dist/tools.js +204 -9
- package/docs/0.1.0-readiness.md +9 -9
- package/docs/a2a.md +6 -2
- package/docs/ag-ui-adoption.md +77 -0
- package/docs/ag-ui.md +39 -42
- package/docs/agent-events.md +5 -1
- package/docs/browser-automation.md +2 -0
- package/docs/coding-agent-tools.md +2 -0
- 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 +11 -9
- package/docs/mcp-tools.md +17 -2
- package/docs/migration.md +22 -0
- package/docs/performance.md +24 -0
- package/docs/postgres-persistence.md +5 -2
- package/docs/public-contracts.md +2 -0
- package/docs/release-and-install.md +52 -693
- package/docs/server.md +9 -6
- package/docs/sqlite-persistence.md +10 -2
- package/docs/supervisors.md +2 -0
- package/docs/tool-effects.md +95 -0
- package/docs/tools.md +4 -0
- package/docs/work-tools.md +4 -0
- package/package.json +10 -2
|
@@ -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
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentEvent, ErrorInfo, Guardrails, JsonObject, OwnershipScope, RunLedger, ToolCallContent, ToolDefinition, ToolExecutionContext, ToolRegistry, ToolResult } from "./contracts.js";
|
|
1
|
+
import type { AgentEvent, ErrorInfo, Guardrails, JsonObject, OwnershipScope, RunLedger, ToolCallContent, ToolDefinition, ToolExecutionContext, ToolEffectStore, ToolRegistry, ToolResult } from "./contracts.js";
|
|
2
2
|
import type { MiddlewareRegistry } from "./middleware.js";
|
|
3
3
|
import { type SecretRedactor } from "./redaction.js";
|
|
4
4
|
import { type DuplicateRegistrationOptions } from "./registry-options.js";
|
|
@@ -42,6 +42,8 @@ export interface DispatchToolCallOptions {
|
|
|
42
42
|
readonly trust?: TrustPolicy;
|
|
43
43
|
readonly redactor?: SecretRedactor;
|
|
44
44
|
readonly ledger?: RunLedger;
|
|
45
|
+
/** Optional shared recovery store. Only declared optional/required effects use it. */
|
|
46
|
+
readonly effectStore?: ToolEffectStore;
|
|
45
47
|
readonly ownership?: OwnershipScope;
|
|
46
48
|
/** Host-verified identity; asserted active before tool side effects when present. */
|
|
47
49
|
readonly identity?: import("./identity.js").AgentIdentity;
|
package/dist/tools.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { isJsonObject } from "./config.js";
|
|
2
2
|
import { GuardrailError, runGuardrails } from "./guardrails.js";
|
|
3
|
-
import { assertIdentityActive, assertIdentityMatchesOwnership } from "./identity.js";
|
|
3
|
+
import { assertIdentityActive, assertIdentityMatchesOwnership, ownershipFromIdentity } from "./identity.js";
|
|
4
4
|
import { createId } from "./ids.js";
|
|
5
5
|
import { errorToErrorInfo, redactRunLedgerRecord, redactSecrets } from "./redaction.js";
|
|
6
6
|
import { assertCanRegister } from "./registry-options.js";
|
|
7
7
|
import { assertPermission, assertTrusted } from "./security.js";
|
|
8
|
+
import { deriveToolEffectKey, toolEffectArgumentsHash, ToolEffectError } from "./tool-effects.js";
|
|
8
9
|
/** Wrap a schema adapter as the existing `ToolValidator` seam used by dispatch and the agent runtime. */
|
|
9
10
|
export function createToolParameterValidator(validator, options = {}) {
|
|
10
11
|
const missingSchema = options.missingSchema ?? "allow";
|
|
@@ -89,8 +90,9 @@ export async function dispatchToolCall(options) {
|
|
|
89
90
|
const postcheck = await checkCall(mediatedCall, options, startedAt);
|
|
90
91
|
if (postcheck)
|
|
91
92
|
return postcheck;
|
|
92
|
-
const
|
|
93
|
-
|
|
93
|
+
const { idempotencyKey: _untrustedKey, ...baseContext } = options.context;
|
|
94
|
+
let context = {
|
|
95
|
+
...baseContext,
|
|
94
96
|
toolCallId: mediatedCall.id,
|
|
95
97
|
identity: options.identity ?? options.context.identity,
|
|
96
98
|
progress: async (progress, metadata) => {
|
|
@@ -135,17 +137,37 @@ export async function dispatchToolCall(options) {
|
|
|
135
137
|
const validation = await options.validate?.(tool, mediatedCall.arguments, context);
|
|
136
138
|
if (validation)
|
|
137
139
|
return blocked(mediatedCall, context, "validation_failed", toErrorInfo(validation, secrets), options, startedAt);
|
|
140
|
+
let effect;
|
|
141
|
+
try {
|
|
142
|
+
const prepared = await prepareToolEffect(tool, mediatedCall, context, options);
|
|
143
|
+
if (prepared.result)
|
|
144
|
+
return prepared.result;
|
|
145
|
+
context = prepared.context;
|
|
146
|
+
effect = prepared.effect;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
return blocked(mediatedCall, context, "execution_denied", errorToErrorInfo(error, secrets), options, startedAt);
|
|
150
|
+
}
|
|
138
151
|
try {
|
|
139
152
|
await options.beforeExecute?.(mediatedCall, tool, context);
|
|
140
153
|
}
|
|
141
154
|
catch (error) {
|
|
142
|
-
|
|
155
|
+
await failBeforeEffect(effect, isSuspended(error) ? "failed_retryable" : "failed_terminal");
|
|
156
|
+
if (isSuspended(error))
|
|
143
157
|
throw error;
|
|
144
158
|
return blocked(mediatedCall, context, "execution_denied", errorToErrorInfo(error, secrets), options, startedAt);
|
|
145
159
|
}
|
|
146
|
-
|
|
147
|
-
|
|
160
|
+
let completedResult;
|
|
161
|
+
let dispatchAttempted = false;
|
|
148
162
|
try {
|
|
163
|
+
await options.emit?.({ type: "tool_execution_started", sessionId: context.sessionId, runId: context.runId, call: mediatedCall });
|
|
164
|
+
await appendToolCallRecord(options, "started", mediatedCall, startedAt, {});
|
|
165
|
+
if (effect) {
|
|
166
|
+
dispatchAttempted = true;
|
|
167
|
+
const record = await effect.store.markDispatched(transition(effect));
|
|
168
|
+
effect.expectedVersion = record.version;
|
|
169
|
+
effect.dispatched = true;
|
|
170
|
+
}
|
|
149
171
|
const raw = await tool.execute(mediatedCall.arguments, context);
|
|
150
172
|
const mediatedResult = await (options.middleware?.run("tool_result", raw) ?? raw);
|
|
151
173
|
const outputGuards = await runGuardrails({
|
|
@@ -166,16 +188,43 @@ export async function dispatchToolCall(options) {
|
|
|
166
188
|
if (outputGuards.terminal) {
|
|
167
189
|
if (outputGuards.terminal.action !== "block")
|
|
168
190
|
throw new GuardrailError(outputGuards.terminal);
|
|
191
|
+
if (effect)
|
|
192
|
+
return finishUnknownEffect(effect, mediatedCall, context, options, startedAt);
|
|
169
193
|
return blocked(mediatedCall, context, "guardrail_blocked", { message: "Tool result blocked by guardrail" }, options, startedAt);
|
|
170
194
|
}
|
|
195
|
+
if (effect && mediatedResult.error)
|
|
196
|
+
return finishUnknownEffect(effect, mediatedCall, context, options, startedAt);
|
|
171
197
|
const result = options.redactor?.redact(mediatedResult) ?? mediatedResult;
|
|
198
|
+
if (effect) {
|
|
199
|
+
try {
|
|
200
|
+
const record = await effect.store.complete({ ...transition(effect), result });
|
|
201
|
+
effect.expectedVersion = record.version;
|
|
202
|
+
effect.completed = true;
|
|
203
|
+
completedResult = record.result ?? result;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return finishUnknownEffect(effect, mediatedCall, context, options, startedAt);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
completedResult ??= result;
|
|
172
210
|
const finishedAt = new Date().toISOString();
|
|
173
211
|
const metadata = toolExecutionMetadata(startedAt, "finished");
|
|
174
|
-
await options.emit?.({
|
|
175
|
-
|
|
176
|
-
|
|
212
|
+
await options.emit?.({
|
|
213
|
+
type: "tool_execution_finished",
|
|
214
|
+
sessionId: context.sessionId,
|
|
215
|
+
runId: context.runId,
|
|
216
|
+
result: completedResult,
|
|
217
|
+
metadata,
|
|
218
|
+
});
|
|
219
|
+
await appendToolCallRecord(options, "finished", mediatedCall, startedAt, { finishedAt, result: completedResult });
|
|
220
|
+
return completedResult;
|
|
177
221
|
}
|
|
178
222
|
catch (error) {
|
|
223
|
+
if (completedResult)
|
|
224
|
+
return completedResult;
|
|
225
|
+
if (effect && (effect.dispatched || dispatchAttempted))
|
|
226
|
+
return finishUnknownEffect(effect, mediatedCall, context, options, startedAt);
|
|
227
|
+
await failBeforeEffect(effect, "failed_terminal");
|
|
179
228
|
if (error instanceof GuardrailError)
|
|
180
229
|
throw error;
|
|
181
230
|
const info = errorToErrorInfo(error, secrets);
|
|
@@ -194,6 +243,152 @@ export async function dispatchToolCall(options) {
|
|
|
194
243
|
return result;
|
|
195
244
|
}
|
|
196
245
|
}
|
|
246
|
+
async function prepareToolEffect(tool, call, context, options) {
|
|
247
|
+
const identity = context.identity;
|
|
248
|
+
const declaration = resolveToolEffectDeclaration(tool, call.arguments, context);
|
|
249
|
+
if (!declaration || declaration.kind === "none" || declaration.idempotency === "none")
|
|
250
|
+
return { context };
|
|
251
|
+
if (!identity && declaration.idempotency === "unsupported")
|
|
252
|
+
return { context };
|
|
253
|
+
if (!identity)
|
|
254
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_CONFLICT", "verified identity is required for a durable tool effect");
|
|
255
|
+
const ownership = ownershipFromIdentity(identity);
|
|
256
|
+
const argumentsHash = toolEffectArgumentsHash(call.arguments);
|
|
257
|
+
const base = {
|
|
258
|
+
identity,
|
|
259
|
+
ownership,
|
|
260
|
+
sessionId: context.sessionId,
|
|
261
|
+
runId: context.runId,
|
|
262
|
+
toolCallId: call.id,
|
|
263
|
+
toolName: call.name,
|
|
264
|
+
argumentsHash,
|
|
265
|
+
};
|
|
266
|
+
const key = { ...base, key: deriveToolEffectKey(base), signal: context.signal };
|
|
267
|
+
const keyedContext = { ...context, idempotencyKey: key.key };
|
|
268
|
+
if (declaration.idempotency === "tool_managed" || declaration.idempotency === "unsupported")
|
|
269
|
+
return { context: keyedContext };
|
|
270
|
+
const store = options.effectStore;
|
|
271
|
+
if (!store) {
|
|
272
|
+
if (declaration.idempotency === "required")
|
|
273
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_REQUIRED", "durable tool effect store is required");
|
|
274
|
+
return { context: keyedContext };
|
|
275
|
+
}
|
|
276
|
+
let begun;
|
|
277
|
+
try {
|
|
278
|
+
begun = await store.begin(key);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (error instanceof ToolEffectError)
|
|
282
|
+
throw error;
|
|
283
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_UNKNOWN", "tool effect claim outcome is unknown");
|
|
284
|
+
}
|
|
285
|
+
if (begun.outcome === "existing")
|
|
286
|
+
return { context: keyedContext, result: replayEffectResult(begun.record) };
|
|
287
|
+
if (!begun.record.claimToken)
|
|
288
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_UNKNOWN", "tool effect claim outcome is unknown");
|
|
289
|
+
return {
|
|
290
|
+
context: keyedContext,
|
|
291
|
+
effect: {
|
|
292
|
+
store,
|
|
293
|
+
key,
|
|
294
|
+
claimToken: begun.record.claimToken,
|
|
295
|
+
expectedVersion: begun.record.version,
|
|
296
|
+
dispatched: false,
|
|
297
|
+
completed: false,
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function resolveToolEffectDeclaration(tool, args, context) {
|
|
302
|
+
const classifierContext = Object.freeze({
|
|
303
|
+
sessionId: context.sessionId,
|
|
304
|
+
runId: context.runId,
|
|
305
|
+
toolCallId: context.toolCallId,
|
|
306
|
+
signal: context.signal,
|
|
307
|
+
metadata: context.metadata,
|
|
308
|
+
});
|
|
309
|
+
const declaration = typeof tool.effect === "function" ? tool.effect(args, classifierContext) : tool.effect;
|
|
310
|
+
if (!declaration)
|
|
311
|
+
return undefined;
|
|
312
|
+
if (!["none", "local_mutation", "external_mutation"].includes(declaration.kind) ||
|
|
313
|
+
!["none", "optional", "required", "tool_managed", "unsupported"].includes(declaration.idempotency) ||
|
|
314
|
+
(declaration.kind === "none" && declaration.idempotency !== "none")) {
|
|
315
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_LIMIT", "tool effect declaration is invalid");
|
|
316
|
+
}
|
|
317
|
+
return declaration;
|
|
318
|
+
}
|
|
319
|
+
function replayEffectResult(record) {
|
|
320
|
+
if (record.status === "completed") {
|
|
321
|
+
if (record.result)
|
|
322
|
+
return record.result;
|
|
323
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_COMPLETED", "tool effect already completed without replayable result");
|
|
324
|
+
}
|
|
325
|
+
if (record.status === "dispatched" || record.status === "unknown") {
|
|
326
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_UNKNOWN", "tool effect outcome requires reconciliation");
|
|
327
|
+
}
|
|
328
|
+
throw new ToolEffectError("ERR_PRISM_TOOL_EFFECT_CONFLICT", "tool effect is not dispatchable");
|
|
329
|
+
}
|
|
330
|
+
function transition(effect) {
|
|
331
|
+
return { ...effect.key, claimToken: effect.claimToken, expectedVersion: effect.expectedVersion };
|
|
332
|
+
}
|
|
333
|
+
async function failBeforeEffect(effect, status) {
|
|
334
|
+
if (!effect || effect.dispatched || effect.completed)
|
|
335
|
+
return;
|
|
336
|
+
try {
|
|
337
|
+
await effect.store.fail({
|
|
338
|
+
...transition(effect),
|
|
339
|
+
status,
|
|
340
|
+
failure: { code: "ERR_PRISM_TOOL_EFFECT_PRE_DISPATCH" },
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
// No effect was invoked. A stale/failed pre-dispatch transition only delays a later safe retry.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function unknownEffectResult(effect, call) {
|
|
348
|
+
try {
|
|
349
|
+
let claim = effect.dispatched
|
|
350
|
+
? { claimToken: effect.claimToken, version: effect.expectedVersion }
|
|
351
|
+
: undefined;
|
|
352
|
+
if (!claim) {
|
|
353
|
+
const current = await effect.store.get(effect.key);
|
|
354
|
+
if (current?.status === "dispatched" && current.claimToken)
|
|
355
|
+
claim = { claimToken: current.claimToken, version: current.version };
|
|
356
|
+
}
|
|
357
|
+
if (claim) {
|
|
358
|
+
await effect.store.markUnknown({
|
|
359
|
+
...effect.key,
|
|
360
|
+
claimToken: claim.claimToken,
|
|
361
|
+
expectedVersion: claim.version,
|
|
362
|
+
failure: { code: "ERR_PRISM_TOOL_EFFECT_UNKNOWN" },
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// A post-dispatch persistence error is itself ambiguous; never expose or retry it.
|
|
368
|
+
}
|
|
369
|
+
return effectErrorResult(call, "ERR_PRISM_TOOL_EFFECT_UNKNOWN", "tool effect outcome requires reconciliation");
|
|
370
|
+
}
|
|
371
|
+
async function finishUnknownEffect(effect, call, context, options, startedAt) {
|
|
372
|
+
const result = await unknownEffectResult(effect, call);
|
|
373
|
+
const error = result.error;
|
|
374
|
+
const finishedAt = new Date().toISOString();
|
|
375
|
+
const metadata = toolExecutionMetadata(startedAt, "error");
|
|
376
|
+
try {
|
|
377
|
+
await options.emit?.({ type: "tool_execution_error", sessionId: context.sessionId, runId: context.runId, call, error, metadata });
|
|
378
|
+
await appendToolCallRecord(options, "error", call, startedAt, { finishedAt, result });
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
// The effect is already ambiguous; exposure/ledger failures cannot make it safe to retry.
|
|
382
|
+
}
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
function effectErrorResult(call, code, message) {
|
|
386
|
+
const error = new ToolEffectError(code, message);
|
|
387
|
+
return { toolCallId: call.id, name: call.name, error: errorToErrorInfo(error) };
|
|
388
|
+
}
|
|
389
|
+
function isSuspended(error) {
|
|
390
|
+
return error?.code === "ERR_PRISM_AGENT_RUN_SUSPENDED";
|
|
391
|
+
}
|
|
197
392
|
async function checkCall(call, options, startedAt) {
|
|
198
393
|
const context = options.context;
|
|
199
394
|
const tool = options.registry.get(call.name);
|
package/docs/0.1.0-readiness.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# 0.1.0 / 1.0 Readiness Gates
|
|
2
2
|
|
|
3
|
-
Status: **0.0.
|
|
3
|
+
Status: **0.0.24** is the current release line (Phase 7 distributed events and recoverable tool effects); **1.0** readiness remains operator-gated, not automatic.
|
|
4
4
|
|
|
5
5
|
This page distills runnable readiness gates into one command-per-gate table. The
|
|
6
6
|
**Last evidence** column records the 2026-07-26 **0.0.16** baseline snapshot
|
|
7
7
|
(Phase 11, Node v24.18.0, Linux x86_64). Treat it as historical floor evidence,
|
|
8
8
|
not the current release tag. Re-run each gate on the target release tree before
|
|
9
|
-
cutting 0.0.
|
|
9
|
+
cutting 0.0.24 / 1.0. The decision to cut 1.0 stays with the operator after
|
|
10
10
|
operator-gated legs run in a protected environment and Phase 12 demand evidence
|
|
11
11
|
exists.
|
|
12
12
|
|
|
@@ -14,15 +14,15 @@ Evidence trail: [`docs/review-coverage-2026-07-26-phase-11.md`](./review-coverag
|
|
|
14
14
|
(addenda 0–9), [`docs/release-and-install.md`](./release-and-install.md),
|
|
15
15
|
[`docs/migration.md`](./migration.md), [`docs/performance.md`](./performance.md).
|
|
16
16
|
|
|
17
|
-
## Current line (0.0.
|
|
17
|
+
## Current line (0.0.24)
|
|
18
18
|
|
|
19
19
|
| Item | Status |
|
|
20
20
|
|---|---|
|
|
21
|
-
| Published graph | **47** publishable manifests at **0.0.
|
|
22
|
-
| Phase
|
|
23
|
-
| Docs tripwires | `node --test dist/__tests__/docs.test.js` — migration section `0.0.
|
|
24
|
-
| Protected database evidence | `PRISM_TEST_POSTGRES_URL="$DATABASE_URL" npm run test:postgres`; benchmark
|
|
25
|
-
| Readiness table below | **0.0.16 measured values** remain historical network-free baseline; 0.0.
|
|
21
|
+
| Published graph | **47** publishable manifests at **0.0.24** (`docs/release-and-install.md`) |
|
|
22
|
+
| Phase 7 distributed events/effects | `AgentEventSource`, `ToolEffectStore`, AG-UI MCP/A2A fronting; enterprise `toolEffects`; schema v6/v7 + enterprise migration 002 |
|
|
23
|
+
| Docs tripwires | `node --test dist/__tests__/docs.test.js` — migration section `0.0.23 → 0.0.24 distributed events and recoverable tool effects` |
|
|
24
|
+
| Protected database evidence | `PRISM_TEST_POSTGRES_URL="$DATABASE_URL" npm run test:postgres`; `benchmark-0.0.24.json` under Task 0 ceilings |
|
|
25
|
+
| Readiness table below | **0.0.16 measured values** remain historical network-free baseline; 0.0.24 database evidence is recorded separately |
|
|
26
26
|
|
|
27
27
|
## Gate table
|
|
28
28
|
|
|
@@ -80,7 +80,7 @@ Deterministic budgets (CI gate, `scripts/budget-gate.test.mjs`):
|
|
|
80
80
|
| Root unpacked bytes | 2,043,402 | +5% | 2.1 MB (within) |
|
|
81
81
|
| Root file count | 270 | +5% | 270 |
|
|
82
82
|
| Cold-startup import | 38 ms | ceiling 250 ms | ~38 ms |
|
|
83
|
-
| Aggregate packed (47 manifests, reference only) | 1,217,694 | +10% | remeasure for the 0.0.
|
|
83
|
+
| Aggregate packed (47 manifests, reference only) | 1,217,694 | +10% | remeasure for the 0.0.24 graph before release |
|
|
84
84
|
|
|
85
85
|
Benchmark medians (on-demand evidence, `scripts/benchmark-0.0.16.mjs`, ±25%):
|
|
86
86
|
|