@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/core/hooks.d.ts +22 -0
  3. package/dist/core/hooks.js +22 -3
  4. package/dist/core/memory-engine/engine.js +2 -4
  5. package/dist/core/memory-engine/file-backend.d.ts +66 -7
  6. package/dist/core/memory-engine/file-backend.js +69 -27
  7. package/dist/core/memory-engine/layout.d.ts +31 -2
  8. package/dist/core/memory-engine/layout.js +132 -8
  9. package/dist/core/memory-engine/types.d.ts +6 -5
  10. package/dist/core/permission-rule-consent.d.ts +82 -8
  11. package/dist/core/permission-rule-consent.js +92 -1
  12. package/dist/core/permission-rule-model.d.ts +17 -1
  13. package/dist/core/permission-rule-model.js +21 -0
  14. package/dist/core/permission-rule-org.d.ts +22 -3
  15. package/dist/core/permission-rule-org.js +67 -20
  16. package/dist/core/permission-rule-store.js +2 -2
  17. package/dist/core/permission-rule-sync.d.ts +15 -1
  18. package/dist/core/permission-rule-sync.js +89 -47
  19. package/dist/core/runner/prepare-task.js +8 -3
  20. package/dist/core/runner/runtask.js +8 -1
  21. package/dist/core/task-registry-agent.d.ts +9 -0
  22. package/dist/core/task-registry-agent.js +51 -21
  23. package/dist/core/task-registry-monitor.js +1 -1
  24. package/dist/core/task-registry-shared.d.ts +9 -0
  25. package/dist/core/tool-policy.d.ts +35 -2
  26. package/dist/core/tool-policy.js +37 -3
  27. package/dist/core/tool-result-store.d.ts +108 -7
  28. package/dist/core/tool-result-store.js +95 -15
  29. package/dist/core/types.d.ts +80 -10
  30. package/dist/core/types.js +30 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/stores/file/tool-result-store.d.ts +41 -1
  33. package/dist/stores/file/tool-result-store.js +107 -19
  34. package/dist/tools/fs/fs-bash.d.ts +7 -0
  35. package/dist/tools/fs/fs-shared.d.ts +5 -0
  36. package/dist/tools/fs/fs-shared.js +11 -7
  37. package/dist/tools/fs/index.d.ts +6 -0
  38. package/dist/tools/fs/index.js +2 -0
  39. 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
- if (handle.spillRef === undefined) {
1235
- const ref = buildToolResultRef(sessionId ?? "no-session", handle.id, `c${handle.reviveCycle ?? 0}`);
1236
- await store.put(ref, full, sessionId === undefined ? undefined : toolResultProvenanceOf(sessionId, handle.id));
1237
- handle.spillRef = ref;
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
- return `${clipped}\n\n[full output persisted — call ${OFFLOAD_TOOL_NAME} with ref "${handle.spillRef}" to read it back.]`;
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 fullResult = handle.result ? (handle.resultFull ?? handle.result) : undefined;
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 = handle.status === "failed" && handle.errorKind !== undefined && handle.errorRetryable !== undefined
1262
- ? ` (error_kind: ${handle.errorKind}, retryable: ${handle.errorRetryable}${handle.errorRetryAfterMs !== undefined ? `, retry_after_ms: ${handle.errorRetryAfterMs}` : ""})`
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: ${handle.status}
1271
- ${handle.error ? `error: ${handle.error}${kindClause}
1272
- ` : ""}${handle.result ? `--- result${handle.resultIsPartial ? " (partial — produced before the task was stopped)" : ""} ---
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: handle.status,
1308
+ status: snap.status,
1279
1309
  retrievalStatus: retrieval,
1280
- ...(handle.cycleSeq !== undefined ? { seq: handle.cycleSeq } : {}),
1281
- ...(handle.stoppedBy !== undefined ? { stoppedBy: handle.stoppedBy } : {}),
1282
- ...(handle.error !== undefined ? { error: handle.error } : {}),
1283
- ...(handle.errorCode !== undefined ? { errorCode: handle.errorCode } : {}),
1284
- ...(handle.errorRetryable !== undefined ? { errorRetryable: handle.errorRetryable } : {}),
1285
- ...(handle.errorRetryAfterMs !== undefined ? { errorRetryAfterMs: handle.errorRetryAfterMs } : {}),
1286
- ...(handle.resultIsPartial === true ? { resultIsPartial: true } : {}),
1287
- ...(handle.completionId !== undefined ? { completionId: handle.completionId } : {}),
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
- ...(handle.status === "failed" ? { isError: true } : {}),
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, handle.id);
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
- export type DecisionReason = "rule" | "mode" | "hook" | "safety" | "classifier" | "persisted_rule" | "sandbox" | "org_rule" | "org_unavailable";
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): 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 {};
@@ -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 (!Object.prototype.hasOwnProperty.call(d, RETIRED_TEXT_FIELD))
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
- return { action: "deny", message: RETIRED_TEXT_FIELD_DENY_MESSAGE, decisionReason: "rule" };
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. Write sites whose ref carries the task (the background-agent and monitor spills key
52
- * on the handle id) state it; the offload/budget/projection sites state the session only;
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
- * but a failed put no longer strands the model with a ref pointing at nothing: a tiny lost-marker is
321
- * written under the same ref so the page-back face reports the true cause (C6 "lost at write" and
322
- * "expired" are different answers). If the marker write fails too, the generic miss remains.
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): (toolCallId: string, fullText: string) => 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
- void Promise.resolve(store.put(ref, fullText, provenance)).catch((err) => {
176
+ const reportFailedWrite = (err) => {
160
177
  const cause = err instanceof Error ? err.message : String(err);
161
- void Promise.resolve(store.put(ref, `[offload LOST at write time: ${cause} — the inline preview is all that survived]`, provenance)).catch(() => undefined);
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
- await store.put(ref, full, provenance);
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
- puts.push(Promise.resolve(store.put(detailRef, full, provenance)));
263
- return (`${full.slice(0, OFFLOADED_DETAIL_HEAD_CHARS)}\n` +
264
- `${OFFLOADED_DETAIL_NOTICE_PREFIX}${full.length} chars total; ref "${detailRef}"; the remainder is retained in this run's tool-result store and reads back through the deployment's tool-results face]`);
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 next = v.map((item, i) => {
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
- return w;
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.all(puts);
313
- return { value };
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({