@sema-agent/core 5.27.0 → 5.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/dist/core/hooks.d.ts +22 -0
- package/dist/core/hooks.js +22 -3
- package/dist/core/memory-engine/engine.js +2 -4
- package/dist/core/memory-engine/file-backend.d.ts +66 -7
- package/dist/core/memory-engine/file-backend.js +69 -27
- package/dist/core/memory-engine/layout.d.ts +31 -2
- package/dist/core/memory-engine/layout.js +132 -8
- package/dist/core/memory-engine/types.d.ts +6 -5
- package/dist/core/permission-rule-consent.d.ts +82 -8
- package/dist/core/permission-rule-consent.js +92 -1
- package/dist/core/permission-rule-model.d.ts +17 -1
- package/dist/core/permission-rule-model.js +21 -0
- package/dist/core/permission-rule-org.d.ts +22 -3
- package/dist/core/permission-rule-org.js +67 -20
- package/dist/core/permission-rule-store.js +2 -2
- package/dist/core/permission-rule-sync.d.ts +15 -1
- package/dist/core/permission-rule-sync.js +89 -47
- package/dist/core/runner/prepare-task.js +8 -3
- package/dist/core/runner/runtask.js +8 -1
- package/dist/core/task-registry-agent.d.ts +9 -0
- package/dist/core/task-registry-agent.js +51 -21
- package/dist/core/task-registry-monitor.js +1 -1
- package/dist/core/task-registry-shared.d.ts +9 -0
- package/dist/core/tool-policy.d.ts +35 -2
- package/dist/core/tool-policy.js +37 -3
- package/dist/core/tool-result-store.d.ts +108 -7
- package/dist/core/tool-result-store.js +95 -15
- package/dist/core/types.d.ts +80 -10
- package/dist/core/types.js +30 -1
- package/dist/index.d.ts +2 -2
- package/dist/stores/file/tool-result-store.d.ts +41 -1
- package/dist/stores/file/tool-result-store.js +107 -19
- package/dist/tools/fs/fs-bash.d.ts +7 -0
- package/dist/tools/fs/fs-shared.d.ts +5 -0
- package/dist/tools/fs/fs-shared.js +11 -7
- package/dist/tools/fs/index.d.ts +6 -0
- package/dist/tools/fs/index.js +2 -0
- package/package.json +1 -1
|
@@ -981,6 +981,7 @@ export async function reviveBackgroundAgentLane(core, id, access, abort) {
|
|
|
981
981
|
handle.result = undefined;
|
|
982
982
|
handle.resultFull = undefined;
|
|
983
983
|
handle.spillRef = undefined;
|
|
984
|
+
handle.spillFailed = undefined;
|
|
984
985
|
handle.error = undefined;
|
|
985
986
|
handle.errorCode = undefined;
|
|
986
987
|
handle.errorRetryable = undefined;
|
|
@@ -1226,17 +1227,33 @@ ${clipTaskOutput(row.finalOutputFull ?? row.finalOutput)}` : "(no result text)"}
|
|
|
1226
1227
|
...(row.status === "failed" ? { isError: true } : {}),
|
|
1227
1228
|
};
|
|
1228
1229
|
}
|
|
1230
|
+
const AGENT_SPILL_FAILED_NOTE = "\n\n[full output could not be spilled to the offload store (the write failed or went unconfirmed) — no ref is available to read the dropped middle back.]";
|
|
1229
1231
|
export async function spillClippedAgentResult(handle, full, clipped, store, sessionId) {
|
|
1230
1232
|
if (clipped === full)
|
|
1231
1233
|
return clipped;
|
|
1232
1234
|
if (store === undefined)
|
|
1233
1235
|
return clipped;
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1236
|
+
const disclose = (ref) => `${clipped}\n\n[full output persisted — call ${OFFLOAD_TOOL_NAME} with ref "${ref}" to read it back.]`;
|
|
1237
|
+
if (handle.spillRef !== undefined)
|
|
1238
|
+
return disclose(handle.spillRef);
|
|
1239
|
+
if (handle.spillFailed === true)
|
|
1240
|
+
return `${clipped}${AGENT_SPILL_FAILED_NOTE}`;
|
|
1241
|
+
const mintCycle = handle.reviveCycle ?? 0;
|
|
1242
|
+
const ref = buildToolResultRef(sessionId ?? "no-session", handle.id, `c${mintCycle}`);
|
|
1243
|
+
try {
|
|
1244
|
+
await store.put(ref, full, sessionId === undefined ? undefined : toolResultProvenanceOf(sessionId));
|
|
1245
|
+
}
|
|
1246
|
+
catch {
|
|
1247
|
+
if ((handle.reviveCycle ?? 0) === mintCycle) {
|
|
1248
|
+
if (handle.spillRef !== undefined)
|
|
1249
|
+
return disclose(handle.spillRef);
|
|
1250
|
+
handle.spillFailed = true;
|
|
1251
|
+
}
|
|
1252
|
+
return `${clipped}${AGENT_SPILL_FAILED_NOTE}`;
|
|
1238
1253
|
}
|
|
1239
|
-
|
|
1254
|
+
if ((handle.reviveCycle ?? 0) === mintCycle)
|
|
1255
|
+
handle.spillRef = ref;
|
|
1256
|
+
return disclose(ref);
|
|
1240
1257
|
}
|
|
1241
1258
|
export async function pollBackgroundAgentLane(handle, deadline, signal, oneShot, store, sessionId) {
|
|
1242
1259
|
while (handle.status === "running" && deadline !== undefined && Date.now() < deadline && !signal?.aborted) {
|
|
@@ -1256,10 +1273,23 @@ The agent is durably suspended, waiting for an approval decision. It resumes whe
|
|
|
1256
1273
|
}),
|
|
1257
1274
|
};
|
|
1258
1275
|
}
|
|
1259
|
-
const
|
|
1276
|
+
const snap = {
|
|
1277
|
+
status: handle.status,
|
|
1278
|
+
result: handle.result,
|
|
1279
|
+
resultIsPartial: handle.resultIsPartial,
|
|
1280
|
+
error: handle.error,
|
|
1281
|
+
errorKind: handle.errorKind,
|
|
1282
|
+
errorCode: handle.errorCode,
|
|
1283
|
+
errorRetryable: handle.errorRetryable,
|
|
1284
|
+
errorRetryAfterMs: handle.errorRetryAfterMs,
|
|
1285
|
+
cycleSeq: handle.cycleSeq,
|
|
1286
|
+
stoppedBy: handle.stoppedBy,
|
|
1287
|
+
completionId: handle.completionId,
|
|
1288
|
+
};
|
|
1289
|
+
const fullResult = snap.result ? (handle.resultFull ?? snap.result) : undefined;
|
|
1260
1290
|
const resultText = fullResult !== undefined ? await spillClippedAgentResult(handle, fullResult, clipTaskOutput(fullResult, handle.outputFile), store, sessionId) : undefined;
|
|
1261
|
-
const kindClause =
|
|
1262
|
-
? ` (error_kind: ${
|
|
1291
|
+
const kindClause = snap.status === "failed" && snap.errorKind !== undefined && snap.errorRetryable !== undefined
|
|
1292
|
+
? ` (error_kind: ${snap.errorKind}, retryable: ${snap.errorRetryable}${snap.errorRetryAfterMs !== undefined ? `, retry_after_ms: ${snap.errorRetryAfterMs}` : ""})`
|
|
1263
1293
|
: "";
|
|
1264
1294
|
const body = running
|
|
1265
1295
|
? oneShot === true
|
|
@@ -1267,26 +1297,26 @@ The agent is durably suspended, waiting for an approval decision. It resumes whe
|
|
|
1267
1297
|
This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput({ task_id: "${handle.id}", block: true }). If it is still running after the wait, wait again (bounded) rather than ending the turn, or write out your best available answer now if you are near your own time budget.`
|
|
1268
1298
|
: `status: running
|
|
1269
1299
|
The agent is still working — you will be notified when it completes.`
|
|
1270
|
-
: `status: ${
|
|
1271
|
-
${
|
|
1272
|
-
` : ""}${
|
|
1300
|
+
: `status: ${snap.status}
|
|
1301
|
+
${snap.error ? `error: ${snap.error}${kindClause}
|
|
1302
|
+
` : ""}${snap.result ? `--- result${snap.resultIsPartial ? " (partial — produced before the task was stopped)" : ""} ---
|
|
1273
1303
|
${resultText}` : "(no result text)"}`;
|
|
1274
1304
|
return {
|
|
1275
1305
|
content: delimitUntrusted(`TaskOutput ${handle.id}`, body),
|
|
1276
1306
|
details: buildAgentPollDetails({
|
|
1277
1307
|
taskId: handle.id,
|
|
1278
|
-
status:
|
|
1308
|
+
status: snap.status,
|
|
1279
1309
|
retrievalStatus: retrieval,
|
|
1280
|
-
...(
|
|
1281
|
-
...(
|
|
1282
|
-
...(
|
|
1283
|
-
...(
|
|
1284
|
-
...(
|
|
1285
|
-
...(
|
|
1286
|
-
...(
|
|
1287
|
-
...(
|
|
1310
|
+
...(snap.cycleSeq !== undefined ? { seq: snap.cycleSeq } : {}),
|
|
1311
|
+
...(snap.stoppedBy !== undefined ? { stoppedBy: snap.stoppedBy } : {}),
|
|
1312
|
+
...(snap.error !== undefined ? { error: snap.error } : {}),
|
|
1313
|
+
...(snap.errorCode !== undefined ? { errorCode: snap.errorCode } : {}),
|
|
1314
|
+
...(snap.errorRetryable !== undefined ? { errorRetryable: snap.errorRetryable } : {}),
|
|
1315
|
+
...(snap.errorRetryAfterMs !== undefined ? { errorRetryAfterMs: snap.errorRetryAfterMs } : {}),
|
|
1316
|
+
...(snap.resultIsPartial === true ? { resultIsPartial: true } : {}),
|
|
1317
|
+
...(snap.completionId !== undefined ? { completionId: snap.completionId } : {}),
|
|
1288
1318
|
}),
|
|
1289
|
-
...(
|
|
1319
|
+
...(snap.status === "failed" ? { isError: true } : {}),
|
|
1290
1320
|
};
|
|
1291
1321
|
}
|
|
1292
1322
|
export async function stopBackgroundAgentLane(core, handle) {
|
|
@@ -68,7 +68,7 @@ function spillRolledMonitorChunk(handle, stream, dropped) {
|
|
|
68
68
|
else
|
|
69
69
|
handle.spillErrSegCount = n + 1;
|
|
70
70
|
try {
|
|
71
|
-
const provenance = handle.spillSessionId === undefined ? undefined : toolResultProvenanceOf(handle.spillSessionId
|
|
71
|
+
const provenance = handle.spillSessionId === undefined ? undefined : toolResultProvenanceOf(handle.spillSessionId);
|
|
72
72
|
void Promise.resolve(store.put(ref, dropped, provenance)).catch(() => {
|
|
73
73
|
handle.spillFailed = true;
|
|
74
74
|
});
|
|
@@ -324,6 +324,15 @@ export interface BackgroundAgentTaskHandle extends SemaTaskHandle {
|
|
|
324
324
|
* eventual spill lands under a DIFFERENT ref rather than colliding with (and being silently refused
|
|
325
325
|
* by the write-once store under) cycle 1's. */
|
|
326
326
|
spillRef?: string;
|
|
327
|
+
/** Backlog #169 — the spill `put` for the CURRENT revive cycle threw or rejected. One-time latch,
|
|
328
|
+
* scoped to the cycle ({@link import("./task-registry-agent.js").reviveBackgroundAgentLane} clears
|
|
329
|
+
* it in lockstep with `spillRef`): later polls serve the clipped text with an honest ref-free loss
|
|
330
|
+
* note instead of retrying the write — the ref is minted deterministically, so the known permanent
|
|
331
|
+
* failure class (the store's write-once/provenance conflict) would fail identically on every poll,
|
|
332
|
+
* each retry a guaranteed-failure round trip to a durable backend. The monitor lane's
|
|
333
|
+
* `spillFailed` twin (same degrade discipline, different write shape — that one is fire-and-forget,
|
|
334
|
+
* this one is awaited). */
|
|
335
|
+
spillFailed?: true;
|
|
327
336
|
error?: string;
|
|
328
337
|
/** RB-386② ([2090]) — the machine-readable failure code beside `error`, threaded from the settle
|
|
329
338
|
* mint point (subagent.ts computes it ONCE from the child's TaskResult.errorCode taxonomy /
|
|
@@ -78,7 +78,8 @@ export interface ToolCallRequest {
|
|
|
78
78
|
* - `"org_unavailable"` — an org-governed deployment could not adjudicate against a snapshot, so the
|
|
79
79
|
* whole decision boundary failed closed (see `ORG_UNAVAILABLE_DECISION_REASON`, the single
|
|
80
80
|
* spelling this word is minted from). */
|
|
81
|
-
|
|
81
|
+
declare const DECISION_REASONS: readonly ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable"];
|
|
82
|
+
export type DecisionReason = (typeof DECISION_REASONS)[number];
|
|
82
83
|
/**
|
|
83
84
|
* WHO (or what) ENDED an approval — the machine-readable twin of a settlement's human-readable text,
|
|
84
85
|
* so a consumer tells "a person decided this" from "nobody answered" without prose-matching a sentence.
|
|
@@ -336,8 +337,39 @@ export declare function constraintChainDigest(chain: readonly ConstraintChainEnt
|
|
|
336
337
|
* out-of-contract decision object, so it is not a rewrite this layer is willing to report as audited.
|
|
337
338
|
* (A rewrite from an EARLIER, contract-shaped policy still rides out — {@link combinePolicies} adds it
|
|
338
339
|
* on the deny path per the observer contract.)
|
|
340
|
+
*
|
|
341
|
+
* The second arm screens `decisionReason` against the closed set ({@link DecisionReason}): a
|
|
342
|
+
* deployment-authored `ToolPolicy.check` returning a value outside it would otherwise ride silently
|
|
343
|
+
* into every consumer that BRANCHES on the word — provenance-keyed exclusions, audit rows, checkpoint
|
|
344
|
+
* discriminants — each of which would treat the unclassifiable value as "none of the reasons I know",
|
|
345
|
+
* a meaning the producing policy never chose. Same doctrine as the retired-field arm: out-of-contract
|
|
346
|
+
* input to the permission face is refused loudly, not passed through as an accidental tenth reason.
|
|
347
|
+
* An ABSENT `decisionReason` stays legal (it is optional; `undefined` is the typed spelling of
|
|
348
|
+
* absence), and hook results never reach here with a foreign word — the hook seam stamps its own
|
|
349
|
+
* (`hooks.ts`) — so this arm's live producers are exactly the deployment policies the tripwire exists
|
|
350
|
+
* to screen. Unlike the retired-field arm this one reads the property PLAINLY (prototype chain
|
|
351
|
+
* included), because it answers a different question: the retired arm asks whether the AUTHOR wrote
|
|
352
|
+
* the old field (own property = authorship), this arm asks what a CONSUMER would read — and every
|
|
353
|
+
* consumer branches on a plain `d.decisionReason` read, which the prototype can satisfy.
|
|
354
|
+
*
|
|
355
|
+
* SCOPE of the carrier check (ruled after three review rounds converged on the same root): it screens
|
|
356
|
+
* STRUCTURALLY unstable carriers — getters, prototype-supplied values — i.e. shapes an ordinary
|
|
357
|
+
* deployment can write by accident. It does NOT try to defeat a Proxy whose descriptor trap reports a
|
|
358
|
+
* data property while its get trap stays stateful: same-process JavaScript has no trust boundary a
|
|
359
|
+
* function can enforce (an adversary who ships such a Proxy can as easily patch this module), so
|
|
360
|
+
* chasing that shape adds complexity without adding a guarantee. The screen's promise is against
|
|
361
|
+
* drift and accident, not against a hostile co-resident.
|
|
362
|
+
*
|
|
363
|
+
* `reasonIsNonInput` (the HOOK seam's spelling — merged-code scan, 5.28 window): at that seam the
|
|
364
|
+
* field is documented as DISCARDED — every mint point downstream unconditionally re-stamps
|
|
365
|
+
* `decisionReason:"hook"` (fold, deny arm, delegated twin), and the allow path never reads it — so
|
|
366
|
+
* both reason arms are skipped there: refusing a value that cannot travel would fail-close a call
|
|
367
|
+
* over a field with no consumer, which inverted the discard contract the stamp exists to enforce.
|
|
368
|
+
* The retired-`reason` arm still applies (that one is about the MESSAGE channel, which does travel).
|
|
339
369
|
*/
|
|
340
|
-
export declare function refuseOutOfContractDecision(d: PermissionResult
|
|
370
|
+
export declare function refuseOutOfContractDecision(d: PermissionResult, opts?: {
|
|
371
|
+
reasonIsNonInput?: boolean;
|
|
372
|
+
}): PermissionResult;
|
|
341
373
|
/** The raw tool-name lists a name-keyed policy was built from (audit feed, see block note above). */
|
|
342
374
|
export interface ToolPolicyNameSets {
|
|
343
375
|
readonly allow?: readonly string[];
|
|
@@ -851,3 +883,4 @@ export type ResolvedAsk = PermissionResult & {
|
|
|
851
883
|
* have ended, and `decisionReason: "mode"` is already the honest word for what produced them.
|
|
852
884
|
*/
|
|
853
885
|
export declare function resolveAsk(req: AskRequest, onAsk: OnAsk | undefined, signal?: AbortSignal): Promise<ResolvedAsk>;
|
|
886
|
+
export {};
|
package/dist/core/tool-policy.js
CHANGED
|
@@ -7,6 +7,8 @@ import { boundInputHashOf } from "./canonical-json.js";
|
|
|
7
7
|
import { inlineUntrusted } from "./untrusted-text.js";
|
|
8
8
|
import { parsePermissionRule } from "./permission-rules.js";
|
|
9
9
|
import { isAbsolutePathForm, isWinFormPath, writeTargetPath } from "../tools/fs/safety.js";
|
|
10
|
+
const DECISION_REASONS = ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable"];
|
|
11
|
+
const DECISION_REASON_SET = new Set(DECISION_REASONS);
|
|
10
12
|
export const APPROVAL_SETTLED_BY_VALUES = ["human", "timeout", "aborted"];
|
|
11
13
|
export function isApprovalSettledBy(v) {
|
|
12
14
|
return typeof v === "string" && APPROVAL_SETTLED_BY_VALUES.includes(v);
|
|
@@ -79,12 +81,44 @@ const ALLOW = { action: "allow" };
|
|
|
79
81
|
const RETIRED_TEXT_FIELD = "reason";
|
|
80
82
|
const RETIRED_TEXT_FIELD_DENY_MESSAGE = `a permission decision carries the retired "${RETIRED_TEXT_FIELD}" field — rename it to "message" (the one text field ` +
|
|
81
83
|
`a decision carries); denied fail-closed rather than executing a decision whose text this layer cannot read`;
|
|
82
|
-
export function refuseOutOfContractDecision(d) {
|
|
84
|
+
export function refuseOutOfContractDecision(d, opts) {
|
|
83
85
|
if (typeof d !== "object" || d === null)
|
|
84
86
|
return d;
|
|
85
|
-
if (
|
|
87
|
+
if (Object.prototype.hasOwnProperty.call(d, RETIRED_TEXT_FIELD)) {
|
|
88
|
+
return { action: "deny", message: RETIRED_TEXT_FIELD_DENY_MESSAGE, decisionReason: "rule" };
|
|
89
|
+
}
|
|
90
|
+
if (opts?.reasonIsNonInput === true)
|
|
91
|
+
return d;
|
|
92
|
+
const dr = d.decisionReason;
|
|
93
|
+
if (dr === undefined)
|
|
86
94
|
return d;
|
|
87
|
-
|
|
95
|
+
if (!DECISION_REASON_SET.has(dr)) {
|
|
96
|
+
let rendered;
|
|
97
|
+
try {
|
|
98
|
+
rendered = JSON.stringify(String(dr).slice(0, 64));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
rendered = `[unprintable ${typeof dr}]`;
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
action: "deny",
|
|
105
|
+
message: `a permission decision carries an unrecognized "decisionReason" value (${rendered}) — ` +
|
|
106
|
+
`the closed set is ${DECISION_REASONS.join("/")}; denied fail-closed rather than letting consumers ` +
|
|
107
|
+
`branch on a value this layer cannot classify`,
|
|
108
|
+
decisionReason: "rule",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const carrier = Object.getOwnPropertyDescriptor(d, "decisionReason");
|
|
112
|
+
if (carrier === undefined || !("value" in carrier)) {
|
|
113
|
+
return {
|
|
114
|
+
action: "deny",
|
|
115
|
+
message: `a permission decision supplies "decisionReason" through ${carrier === undefined ? "its prototype" : "an accessor"} — ` +
|
|
116
|
+
`contract fields must be own data properties, so consumers re-reading the field cannot be shown a ` +
|
|
117
|
+
`different value than this screen validated; denied fail-closed`,
|
|
118
|
+
decisionReason: "rule",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return d;
|
|
88
122
|
}
|
|
89
123
|
const DEADLINE_ELAPSED = Symbol("approval.deadline_elapsed");
|
|
90
124
|
function withTimeout(p, ms, onTimeout) {
|
|
@@ -48,8 +48,10 @@ export interface ToolResultStore {
|
|
|
48
48
|
* (session, tool call) is legitimately written by two different tasks of one session — the
|
|
49
49
|
* Runner shares one store across its tasks on purpose, and a BYOM brain may mint the same
|
|
50
50
|
* tool-call id in both — so stamping a `taskId` there would turn a designed sharing case into a
|
|
51
|
-
* refusal.
|
|
52
|
-
*
|
|
51
|
+
* refusal. Every engine write site therefore states the session only (#167) — including the
|
|
52
|
+
* background-agent and monitor spills, whose ref DOES carry the registry handle id: that id is
|
|
53
|
+
* an addressing segment of the ref, not an ownership coordinate a read-face caller can present
|
|
54
|
+
* (see {@link ToolResultProvenance.taskId} for the namespace rule);
|
|
53
55
|
* - an entry stored **without** provenance (written by an older engine, or by a call site that has
|
|
54
56
|
* none) stays UNOWNED: `put` never back-fills an owner onto it (that would be adoption by a party
|
|
55
57
|
* that cannot prove it wrote it) and {@link ownerOf} keeps answering `undefined`, which a read face
|
|
@@ -72,12 +74,77 @@ export interface ToolResultStore {
|
|
|
72
74
|
* that cannot answer this cannot back a host read face at all.
|
|
73
75
|
*/
|
|
74
76
|
ownerOf?(ref: string): Promise<ToolResultProvenance | undefined> | ToolResultProvenance | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* Erase every entry this store recorded as belonging to `sessionId` — the DELETE half of the same
|
|
79
|
+
* ownership coordinate {@link ownerOf} answers on. Without it a durable backend keeps a deleted
|
|
80
|
+
* session's offloaded and spilled results on its media indefinitely: `put`/`get` alone give a
|
|
81
|
+
* deployment no way to complete a session deletion, and the engine ships no scheduler that would
|
|
82
|
+
* eventually collect them.
|
|
83
|
+
*
|
|
84
|
+
* Rules every implementation follows:
|
|
85
|
+
* - **selection is by RECORDED PROVENANCE, never by parsing the ref.** A ref is an opaque handle:
|
|
86
|
+
* its session segment may be folded ({@link buildToolResultRef}), refs minted before the
|
|
87
|
+
* injective form decompose two ways (`tr_team_blue_x` is both ("team","blue_x") and
|
|
88
|
+
* ("team_blue","x")), and a caller may mint its own. Prefix-matching a ref therefore both
|
|
89
|
+
* misses rows and reaches rows of a NEIGHBOURING session, and over-deletion here destroys a
|
|
90
|
+
* live session's readable bytes. The owner recorded at the winning write is the one coordinate
|
|
91
|
+
* that means exactly one thing, and it is the same one a read face authorizes against;
|
|
92
|
+
* - **`taskId` is ignored in the match.** A stored `{sessionId, taskId}` is a NARROWING of the
|
|
93
|
+
* same session, not a different owner — write sites stamped one before the namespace rule
|
|
94
|
+
* settled the shape (#167), and such rows still belong to the session being deleted;
|
|
95
|
+
* - **an UNOWNED entry is never deleted.** A row whose write stated no provenance (an older
|
|
96
|
+
* engine, a call site that has none, or a two-object backend interrupted between publishing
|
|
97
|
+
* content and owner) belongs to no session this sweep can name, and it is still READABLE via
|
|
98
|
+
* `get` — deleting it on a guess would destroy another session's output. It is counted instead
|
|
99
|
+
* ({@link ToolResultDeletionReport.unattributable}) so the caller learns the deletion was
|
|
100
|
+
* incomplete rather than being told a clean "done";
|
|
101
|
+
* - **idempotent**: deleting a session with no entries is a no-op, and a repeat call after a
|
|
102
|
+
* partial (loudly failed) sweep resumes it;
|
|
103
|
+
* - **concurrency-tolerant**: an entry that disappears between enumeration and removal is honest
|
|
104
|
+
* absence, not an error.
|
|
105
|
+
*
|
|
106
|
+
* Typed OPTIONAL, and deliberately NOT part of the published contract kit
|
|
107
|
+
* (`toolResultStoreContract`): unlike `ownerOf`, a backend that cannot enumerate by owner is still
|
|
108
|
+
* a usable offload store, so its absence must stay a checkable fact rather than a contract breach.
|
|
109
|
+
* Present ⇒ the store can complete a session deletion; absent ⇒ the deployment owns that gap.
|
|
110
|
+
*
|
|
111
|
+
* Implementing it does NOT make a store `retention: "managed"` — that declaration promises the whole
|
|
112
|
+
* {@link import("./retention.js").ManagedRetentionCapability} (domain enumeration, tombstones,
|
|
113
|
+
* audit receipts, scheduled execution), of which this is one caller-driven erase.
|
|
114
|
+
*/
|
|
115
|
+
deleteBySession?(sessionId: string): Promise<ToolResultDeletionReport> | ToolResultDeletionReport;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* What a {@link ToolResultStore.deleteBySession} sweep actually did. A count, not a `void`, because the
|
|
119
|
+
* sweep has a documented INCOMPLETE outcome (unowned entries it must not delete) and a caller running a
|
|
120
|
+
* session-deletion face has to be able to tell "the session's results are gone" from "some rows survive
|
|
121
|
+
* that no session can clear".
|
|
122
|
+
*/
|
|
123
|
+
export interface ToolResultDeletionReport {
|
|
124
|
+
/** Entries removed by THIS call. A retry of an already-swept session reports `0`, not an error. */
|
|
125
|
+
readonly deleted: number;
|
|
126
|
+
/**
|
|
127
|
+
* Entries the sweep could not attribute to ANY session and therefore left in place: stored without
|
|
128
|
+
* provenance, or with an owner record that is present but unreadable. Store-wide, not
|
|
129
|
+
* session-specific — an unowned row may or may not have been this session's, which is precisely why
|
|
130
|
+
* it cannot be deleted — so the same figure recurs on every session's sweep until a migration or an
|
|
131
|
+
* operator clears those rows.
|
|
132
|
+
*/
|
|
133
|
+
readonly unattributable: number;
|
|
75
134
|
}
|
|
76
135
|
/**
|
|
77
136
|
* Backlog #119 — WHO a stored tool result belongs to. Structured (not a single opaque string) because
|
|
78
137
|
* the read face authorizes by COMPARING fields it already knows; `taskId` is present when the write site
|
|
79
138
|
* has one (it is a narrowing of the session, never a substitute for it).
|
|
80
139
|
*
|
|
140
|
+
* #167 (namespace rule): a stamped `taskId` must name a coordinate the read face's CALLER presents —
|
|
141
|
+
* the host's own task/run id — never an engine-internal registry handle id. A handle id lives in a
|
|
142
|
+
* namespace only the engine's in-memory registry can resolve, so an owner stamped with one compares
|
|
143
|
+
* unequal to every caller coordinate forever: the entry becomes readable by NOBODY across the wire
|
|
144
|
+
* (fail-closed, but pointlessly — the disclosure text tells the caller to read it back). The engine's
|
|
145
|
+
* own write sites all state the session only; the handle id, where a ref needs it, is an ADDRESSING
|
|
146
|
+
* segment of the ref, not part of the owner.
|
|
147
|
+
*
|
|
81
148
|
* Deliberately NOT part of it: `toolCallId`. It is a retention/addressing coordinate, not an ownership
|
|
82
149
|
* one — two different tool calls in one session are the same owner.
|
|
83
150
|
*/
|
|
@@ -262,6 +329,12 @@ export declare class InMemoryToolResultStore implements ToolResultStore {
|
|
|
262
329
|
/** #119 — the owner recorded at the winning write; `undefined` for unknown AND for unowned entries,
|
|
263
330
|
* which a read face treats identically (fail-closed). */
|
|
264
331
|
ownerOf(ref: string): ToolResultProvenance | undefined;
|
|
332
|
+
/** Erase this session's entries, selected on the owner recorded at the winning write (the interface
|
|
333
|
+
* states the rules). Implemented here as well as on the durable backends so the bundled pair does
|
|
334
|
+
* not answer the same operation two ways: the entry holds content and owner as ONE value, so the
|
|
335
|
+
* same provenance match applies with none of the file backend's two-object bookkeeping. Deleting
|
|
336
|
+
* from a Map while iterating it is defined (a removed key is simply not revisited). */
|
|
337
|
+
deleteBySession(sessionId: string): ToolResultDeletionReport;
|
|
265
338
|
/** design/80 D-2: true when NOTHING has been offloaded — a durable suspend can then proceed safely even on
|
|
266
339
|
* this in-memory store, because a cross-replica resume has no offloaded result to deref to null. */
|
|
267
340
|
isEmpty(): boolean;
|
|
@@ -316,12 +389,35 @@ export declare function isVolatileOffloadStore(store: ToolResultStore): boolean;
|
|
|
316
389
|
export declare const OFFLOAD_TOOL_NAME = "ReadToolResult";
|
|
317
390
|
/**
|
|
318
391
|
* RB-469-d — the runner's clear-with-offload persist, as ONE construction point (the closure used to
|
|
319
|
-
* live inline in prepare-task). Fire-and-forget by design (the caller returns the ref synchronously)
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
392
|
+
* live inline in prepare-task). Fire-and-forget by design (the caller returns the ref synchronously).
|
|
393
|
+
*
|
|
394
|
+
* #167 (A-025.17) — a failed put REPORTS the loss, it never writes under the ref. The previous arm
|
|
395
|
+
* wrote a lost-marker row under the SAME ref "so the page-back face reports the true cause", and that
|
|
396
|
+
* one write carried two defects:
|
|
397
|
+
* - **revival**: the put and a session purge can race. When the put loses, the marker write lands
|
|
398
|
+
* AFTER the purge — re-inserting a row into a session the deployment just deleted, alive until the
|
|
399
|
+
* backend's own TTL. A failure arm must not be able to out-write a deletion;
|
|
400
|
+
* - **retry poisoning**: the per-request re-clear legitimately re-invokes this persist with the same
|
|
401
|
+
* (toolCallId, fullText) — the write-once retry path. With a marker occupying the ref, that
|
|
402
|
+
* retry's put of the REAL bytes hit the write-once no-op (same owner ⇒ keep what is there), so a
|
|
403
|
+
* transient first failure served the marker forever even though the full text was still in hand.
|
|
404
|
+
* Now the failure arm writes NOTHING (a later persist call retries the real bytes against an empty
|
|
405
|
+
* ref) and announces the failed write loudly instead: through the deployment's structured notice sink
|
|
406
|
+
* when one is wired, else `console.warn` (the loud-bad-value announcement dialect — a silent catch
|
|
407
|
+
* here would be a silent data loss). The announcement claims only what this arm can know: THIS
|
|
408
|
+
* attempt stored nothing — the same (toolCallId, fullText) persist recurs per request (write-once
|
|
409
|
+
* idempotent re-put), so an EARLIER attempt may already have landed the row and a rejected re-put is
|
|
410
|
+
* then no loss at all; declaring "the ref is empty" here would fabricate a data-loss incident on a
|
|
411
|
+
* transient outage. Page-back of a ref no attempt ever landed serves the generic miss, which is
|
|
412
|
+
* honest: nothing is stored under it.
|
|
323
413
|
*/
|
|
324
|
-
export declare function createOffloadPersist(store: ToolResultStore, sessionId: string
|
|
414
|
+
export declare function createOffloadPersist(store: ToolResultStore, sessionId: string,
|
|
415
|
+
/** Structured sink for the loss announcement, delivered through the shared guarded form
|
|
416
|
+
* (`deliverEngineNotice`, #170): a wired FUNCTION seat REPLACES the console line, swallow-guarded
|
|
417
|
+
* (a throwing/rejecting sink never turns an announcement into a failure); a present NON-function
|
|
418
|
+
* seat falls back to `console.warn` plus the once-per-process seat-defect announcement; absent ⇒
|
|
419
|
+
* `console.warn`. */
|
|
420
|
+
onNotice?: (notice: import("./types.js").EngineNotice) => void): (toolCallId: string, fullText: string) => string;
|
|
325
421
|
/** Backlog #119 — build the provenance a write site records, from the two coordinates every write site
|
|
326
422
|
* already has in hand. `taskId` is omitted (not `undefined`-valued) when the run declares none, so the
|
|
327
423
|
* stored shape compares equal across a durable round-trip. */
|
|
@@ -388,6 +484,11 @@ export declare function buildPreview(full: string, ref: string, sizes?: {
|
|
|
388
484
|
* wake needs a durable store (the default in-memory store loses the full text — the preview still stands).
|
|
389
485
|
* Image blocks are left untouched; only text is offloaded. Forwards the `onUpdate` progress callback so a
|
|
390
486
|
* wrapped (e.g. MCP) streaming tool isn't broken.
|
|
487
|
+
*
|
|
488
|
+
* Backlog #175 — a failing store NEVER turns the wrapped tool's success into a failure: the content face
|
|
489
|
+
* falls back to the original (un-offloaded) result, and the `details` walk to a ref-free notice per lost
|
|
490
|
+
* member (see the two arms for why the degrades differ). The wrapper adds a storage step to a tool that
|
|
491
|
+
* has already run; it must not be able to invalidate that run.
|
|
391
492
|
*/
|
|
392
493
|
export declare function withToolResultOffload(tool: AgentTool, store: ToolResultStore, thresholdChars: number, sessionId: string,
|
|
393
494
|
/** RB-374① — LIVE accessor for the session's currently CALLABLE tool set (evaluated per offload,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { defineTool, errorResult } from "./tools.js";
|
|
4
|
+
import { deliverEngineNotice } from "./types.js";
|
|
4
5
|
import { TOOL_SEARCH_NAME } from "./runner/tool-disclosure.js";
|
|
5
6
|
export const TOOL_RESULT_REF_CONFLICT_CODE = "tool_result.ref_conflict";
|
|
6
7
|
export class ToolResultRefConflictError extends Error {
|
|
@@ -94,6 +95,22 @@ export class InMemoryToolResultStore {
|
|
|
94
95
|
ownerOf(ref) {
|
|
95
96
|
return this.map.get(ref)?.provenance;
|
|
96
97
|
}
|
|
98
|
+
deleteBySession(sessionId) {
|
|
99
|
+
let deleted = 0;
|
|
100
|
+
let unattributable = 0;
|
|
101
|
+
for (const [ref, entry] of this.map) {
|
|
102
|
+
if (entry.provenance === undefined) {
|
|
103
|
+
unattributable++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (entry.provenance.sessionId !== sessionId)
|
|
107
|
+
continue;
|
|
108
|
+
this.map.delete(ref);
|
|
109
|
+
this.totalChars -= entry.content.length;
|
|
110
|
+
deleted++;
|
|
111
|
+
}
|
|
112
|
+
return { deleted, unattributable };
|
|
113
|
+
}
|
|
97
114
|
isEmpty() {
|
|
98
115
|
return this.map.size === 0;
|
|
99
116
|
}
|
|
@@ -152,14 +169,23 @@ export function isVolatileOffloadStore(store) {
|
|
|
152
169
|
return store instanceof InMemoryToolResultStore;
|
|
153
170
|
}
|
|
154
171
|
export const OFFLOAD_TOOL_NAME = "ReadToolResult";
|
|
155
|
-
export function createOffloadPersist(store, sessionId) {
|
|
172
|
+
export function createOffloadPersist(store, sessionId, onNotice) {
|
|
156
173
|
const provenance = toolResultProvenanceOf(sessionId);
|
|
157
174
|
return (toolCallId, fullText) => {
|
|
158
175
|
const ref = buildToolResultRef(sessionId, toolCallId, toolResultContentSegment(fullText));
|
|
159
|
-
|
|
176
|
+
const reportFailedWrite = (err) => {
|
|
160
177
|
const cause = err instanceof Error ? err.message : String(err);
|
|
161
|
-
|
|
162
|
-
|
|
178
|
+
const message = `tool-result offload: persisting ref "${ref}" failed (${cause}) — this write attempt stored nothing; ` +
|
|
179
|
+
`unless an earlier attempt already stored this ref, the inline preview is all that survived. ` +
|
|
180
|
+
`A later re-clear of the same result retries the write.`;
|
|
181
|
+
deliverEngineNotice(onNotice, { code: "tool_result.offload_put_failed", message, detail: { ref, sessionId, cause } });
|
|
182
|
+
};
|
|
183
|
+
try {
|
|
184
|
+
void Promise.resolve(store.put(ref, fullText, provenance)).catch(reportFailedWrite);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
reportFailedWrite(err);
|
|
188
|
+
}
|
|
163
189
|
return ref;
|
|
164
190
|
};
|
|
165
191
|
}
|
|
@@ -236,7 +262,12 @@ export function withToolResultOffload(tool, store, thresholdChars, sessionId, re
|
|
|
236
262
|
if (full.length <= PREVIEW_HEAD_CHARS + PREVIEW_TAIL_CHARS)
|
|
237
263
|
return withDetails(res);
|
|
238
264
|
const ref = buildToolResultRef(sessionId, toolCallId, toolResultContentSegment(full));
|
|
239
|
-
|
|
265
|
+
try {
|
|
266
|
+
await store.put(ref, full, provenance);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return withDetails(res);
|
|
270
|
+
}
|
|
240
271
|
const images = res.content.filter((b) => b.type !== "text");
|
|
241
272
|
return withDetails({ ...res, content: [{ type: "text", text: buildPreview(full, ref, undefined, reachableTools?.()) }, ...images] });
|
|
242
273
|
};
|
|
@@ -249,19 +280,34 @@ export function isOffloadedDetailReplacement(s) {
|
|
|
249
280
|
return s.includes(`\n${OFFLOADED_DETAIL_NOTICE_PREFIX}`);
|
|
250
281
|
}
|
|
251
282
|
async function offloadOversizedDetailStrings(details, store, thresholdChars, sessionId, toolCallId, provenance) {
|
|
252
|
-
const puts = [];
|
|
253
|
-
const onStack = new Set();
|
|
254
283
|
const isPlainObject = (v) => {
|
|
255
284
|
if (typeof v !== "object" || v === null)
|
|
256
285
|
return false;
|
|
257
286
|
const p = Object.getPrototypeOf(v);
|
|
258
287
|
return p === Object.prototype || p === null;
|
|
259
288
|
};
|
|
289
|
+
const onStack = new Set();
|
|
290
|
+
const pending = [];
|
|
291
|
+
const rebuilt = new Set();
|
|
260
292
|
const replace = (full, path) => {
|
|
261
293
|
const detailRef = buildToolResultRef(sessionId, toolCallId, path, toolResultContentSegment(full));
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
294
|
+
let done;
|
|
295
|
+
try {
|
|
296
|
+
done = Promise.resolve(store.put(detailRef, full, provenance));
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
done = Promise.reject(err);
|
|
300
|
+
}
|
|
301
|
+
const marker = {};
|
|
302
|
+
pending.push({
|
|
303
|
+
marker,
|
|
304
|
+
done,
|
|
305
|
+
render: (stored) => `${full.slice(0, OFFLOADED_DETAIL_HEAD_CHARS)}\n${OFFLOADED_DETAIL_NOTICE_PREFIX}${full.length} chars total; ` +
|
|
306
|
+
(stored
|
|
307
|
+
? `ref "${detailRef}"; the remainder is retained in this run's tool-result store and reads back through the deployment's tool-results face]`
|
|
308
|
+
: `persisting the remainder to this run's tool-result store failed or went unconfirmed, so no ref is offered — the text above is all this field carries]`),
|
|
309
|
+
});
|
|
310
|
+
return marker;
|
|
265
311
|
};
|
|
266
312
|
const memo = new Map();
|
|
267
313
|
const walk = (v, segs) => {
|
|
@@ -275,14 +321,21 @@ async function offloadOversizedDetailStrings(details, store, thresholdChars, ses
|
|
|
275
321
|
return done;
|
|
276
322
|
onStack.add(v);
|
|
277
323
|
let changed = false;
|
|
278
|
-
const
|
|
324
|
+
const len = v.length;
|
|
325
|
+
const next = new Array(len);
|
|
326
|
+
for (let i = 0; i < len; i++) {
|
|
327
|
+
if (!(i in v))
|
|
328
|
+
continue;
|
|
329
|
+
const item = v[i];
|
|
279
330
|
const w = walk(item, [...segs, i]);
|
|
280
331
|
if (w !== item)
|
|
281
332
|
changed = true;
|
|
282
|
-
|
|
283
|
-
}
|
|
333
|
+
Object.defineProperty(next, i, { value: w, enumerable: true, writable: true, configurable: true });
|
|
334
|
+
}
|
|
284
335
|
onStack.delete(v);
|
|
285
336
|
const result = changed ? next : v;
|
|
337
|
+
if (changed)
|
|
338
|
+
rebuilt.add(next);
|
|
286
339
|
memo.set(v, result);
|
|
287
340
|
return result;
|
|
288
341
|
}
|
|
@@ -303,14 +356,41 @@ async function offloadOversizedDetailStrings(details, store, thresholdChars, ses
|
|
|
303
356
|
}
|
|
304
357
|
onStack.delete(v);
|
|
305
358
|
const result = changed ? next : v;
|
|
359
|
+
if (changed)
|
|
360
|
+
rebuilt.add(next);
|
|
306
361
|
memo.set(v, result);
|
|
307
362
|
return result;
|
|
308
363
|
}
|
|
309
364
|
return v;
|
|
310
365
|
};
|
|
311
366
|
const value = walk(details, []);
|
|
312
|
-
await Promise.
|
|
313
|
-
|
|
367
|
+
const settled = await Promise.allSettled(pending.map((p) => p.done));
|
|
368
|
+
const notices = new Map();
|
|
369
|
+
pending.forEach((p, i) => void notices.set(p.marker, p.render(settled[i].status === "fulfilled")));
|
|
370
|
+
const seen = new Set();
|
|
371
|
+
const finalize = (node) => {
|
|
372
|
+
if (typeof node !== "object" || node === null)
|
|
373
|
+
return node;
|
|
374
|
+
const notice = notices.get(node);
|
|
375
|
+
if (notice !== undefined)
|
|
376
|
+
return notice;
|
|
377
|
+
if (!rebuilt.has(node) || seen.has(node))
|
|
378
|
+
return node;
|
|
379
|
+
seen.add(node);
|
|
380
|
+
if (Array.isArray(node)) {
|
|
381
|
+
for (let i = 0; i < node.length; i++) {
|
|
382
|
+
if (!Object.hasOwn(node, i))
|
|
383
|
+
continue;
|
|
384
|
+
Object.defineProperty(node, i, { value: finalize(node[i]), enumerable: true, writable: true, configurable: true });
|
|
385
|
+
}
|
|
386
|
+
return node;
|
|
387
|
+
}
|
|
388
|
+
const obj = node;
|
|
389
|
+
for (const k of Object.keys(obj))
|
|
390
|
+
Object.defineProperty(obj, k, { value: finalize(obj[k]), enumerable: true, writable: true, configurable: true });
|
|
391
|
+
return node;
|
|
392
|
+
};
|
|
393
|
+
return { value: finalize(value) };
|
|
314
394
|
}
|
|
315
395
|
export function createReadToolResultTool(store) {
|
|
316
396
|
return defineTool({
|