@sema-agent/core 5.25.0 → 5.27.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 (62) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/dist/agents/agent-definition.js +5 -0
  3. package/dist/agents/agent-transcript-tool.d.ts +5 -2
  4. package/dist/agents/agent-transcript-tool.js +2 -1
  5. package/dist/agents/send-message-tool.d.ts +4 -1
  6. package/dist/agents/send-message-tool.js +1 -0
  7. package/dist/agents/subagent.d.ts +6 -2
  8. package/dist/agents/subagent.js +5 -0
  9. package/dist/core/checkpoint-store.d.ts +7 -2
  10. package/dist/core/hooks.d.ts +39 -4
  11. package/dist/core/hooks.js +18 -14
  12. package/dist/core/memory-engine/dual-root.js +3 -1
  13. package/dist/core/memory-engine/engine.d.ts +53 -6
  14. package/dist/core/memory-engine/engine.js +43 -11
  15. package/dist/core/memory-engine/file-backend.d.ts +81 -0
  16. package/dist/core/memory-engine/file-backend.js +250 -24
  17. package/dist/core/memory-engine/index.d.ts +1 -1
  18. package/dist/core/memory-engine/index.js +1 -1
  19. package/dist/core/memory-engine/types.d.ts +8 -1
  20. package/dist/core/memory-vector.d.ts +6 -1
  21. package/dist/core/memory-vector.js +14 -4
  22. package/dist/core/memory.js +1 -6
  23. package/dist/core/permission-rule-consent.js +8 -1
  24. package/dist/core/permission-rule-model.d.ts +70 -5
  25. package/dist/core/permission-rule-model.js +58 -0
  26. package/dist/core/runner/compaction-call-options.d.ts +4 -4
  27. package/dist/core/runner/compaction-call-options.js +3 -4
  28. package/dist/core/runner/prepare-memory.d.ts +34 -15
  29. package/dist/core/runner/prepare-memory.js +99 -26
  30. package/dist/core/runner/prepare-task.d.ts +9 -3
  31. package/dist/core/runner/prepare-task.js +60 -15
  32. package/dist/core/runner/runtask.d.ts +8 -1
  33. package/dist/core/store-contracts/tool-result-store-contract.d.ts +6 -0
  34. package/dist/core/store-contracts/tool-result-store-contract.js +24 -0
  35. package/dist/core/task-registry-agent.d.ts +4 -3
  36. package/dist/core/task-registry-agent.js +3 -3
  37. package/dist/core/task-registry-monitor.js +6 -5
  38. package/dist/core/task-registry.d.ts +6 -3
  39. package/dist/core/tool-policy.d.ts +9 -2
  40. package/dist/core/tool-result-budget.d.ts +1 -1
  41. package/dist/core/tool-result-budget.js +3 -3
  42. package/dist/core/tool-result-store.d.ts +164 -9
  43. package/dist/core/tool-result-store.js +82 -23
  44. package/dist/core/types.d.ts +103 -7
  45. package/dist/core/untrusted-text.d.ts +6 -2
  46. package/dist/core/untrusted-text.js +1 -1
  47. package/dist/engine/loop/types.d.ts +10 -3
  48. package/dist/engine/session/import-validate.js +2 -1
  49. package/dist/index.d.ts +3 -3
  50. package/dist/index.js +3 -3
  51. package/dist/orchestration/run-workflow-tool.d.ts +5 -3
  52. package/dist/orchestration/workflow.d.ts +9 -6
  53. package/dist/orchestration/workflow.js +2 -0
  54. package/dist/prompts/default.d.ts +11 -0
  55. package/dist/prompts/default.js +3 -0
  56. package/dist/stores/file/checkpoint-store.d.ts +2 -1
  57. package/dist/stores/file/fs-atomic.d.ts +1 -1
  58. package/dist/stores/file/index.d.ts +1 -1
  59. package/dist/stores/file/tool-result-store.d.ts +45 -9
  60. package/dist/stores/file/tool-result-store.js +76 -9
  61. package/dist/tools/fs/fs-shared.js +5 -4
  62. package/package.json +1 -1
@@ -29,7 +29,7 @@ import { inlineUntrusted } from "../untrusted-text.js";
29
29
  import { policyAskClassOf } from "../ask-class.js";
30
30
  import { emitTrace } from "../trace.js";
31
31
  import { createSessionRulePolicy } from "./session-rule-policy.js";
32
- import { cloneObserverInput, createHookEnvCapabilities, createPreToolUseConstraintPolicy, formatHookFeedback, runToolGate } from "../hooks.js";
32
+ import { cloneObserverInput, createHookEnvCapabilities, createPreToolUseConstraintPolicy, formatHookFeedback, persistedRuleMandateOf, runToolGate } from "../hooks.js";
33
33
  import { orgRuleVerdictFor } from "../permission-rule-org.js";
34
34
  import { reconcileInterruptedSession } from "../session-reconcile.js";
35
35
  import { CacheBreakDetector, toolsToFingerprintInputs } from "../cache-break-detector.js";
@@ -352,6 +352,11 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
352
352
  e.code = "config.tool_materialize_invalid";
353
353
  throw e;
354
354
  }
355
+ if (spec.memoryPersistenceCapable !== undefined && typeof spec.memoryPersistenceCapable !== "boolean") {
356
+ const e = new Error(`memoryPersistenceCapable must be a boolean when present (got ${JSON.stringify(spec.memoryPersistenceCapable)}) — a non-boolean would silently read as capable.`);
357
+ e.code = "config.memory_persistence_invalid";
358
+ throw e;
359
+ }
355
360
  if (spec.toolMaterializeStrategy === "static" && spec.deferSelfResolve === false) {
356
361
  const e = new Error(`toolMaterializeStrategy "static" cannot be combined with deferSelfResolve: false — with the direct-call ` +
357
362
  `lane disabled a placeholder is never swapped and never self-resolves, so no deferred tool could ever be ` +
@@ -1154,6 +1159,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1154
1159
  ...(spec.additionalDirectories !== undefined ? { additionalDirectories: Object.freeze([...spec.additionalDirectories]) } : {}),
1155
1160
  ...(spec.additionalReadDirectories !== undefined ? { additionalReadDirectories: Object.freeze([...spec.additionalReadDirectories]) } : {}),
1156
1161
  ...(spec.envFacts !== undefined ? { envFacts: { ...spec.envFacts } } : {}),
1162
+ ...(spec.memoryPersistenceCapable !== undefined ? { memoryPersistenceCapable: spec.memoryPersistenceCapable } : {}),
1157
1163
  getApiKeyAndHeaders: spec.getApiKeyAndHeaders,
1158
1164
  parentCwd: taskRootPath,
1159
1165
  ...(centerAdoption !== undefined ? { centerArtifactDigest: centerAdoption.artifact.artifactDigest } : {}),
@@ -1687,20 +1693,24 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1687
1693
  `Set spec.shellGate to "classify" or "always" if this deployment expects doctrine-gated shell behavior.`), { phase: "config", sessionId, classification: "shell-gate-off" });
1688
1694
  }
1689
1695
  if (shellGate !== "off" && !(executionEnv instanceof StubExecutionEnv) && spec.handsReadOnly !== true) {
1690
- shellGatedBash = !egressTools.has("Bash") && !irreversibilityTier.has("Bash");
1691
- irreversibilityTier.set("Bash", shellGate === "always" ? "always" : "maybe");
1696
+ const bashTierBefore = irreversibilityTier.get("Bash");
1697
+ shellGatedBash = !egressTools.has("Bash") && bashTierBefore !== "always" && bashTierBefore !== "maybe";
1698
+ const bashEffectiveTier = shellGate === "always" || bashTierBefore === "always" ? "always" : "maybe";
1699
+ irreversibilityTier.set("Bash", bashEffectiveTier);
1692
1700
  irreversibleTools.add("Bash");
1693
1701
  const shellReadBoundary = () => ({
1694
1702
  roots: [rootCanonical, ...additionalRootsCanonical, ...additionalReadRootsCanonical],
1695
1703
  ...(handsCwdRef?.current !== undefined ? { cwd: handsCwdRef.current } : {}),
1696
1704
  });
1697
- if (shellGate === "classify")
1705
+ if (shellGate === "classify" && shellGatedBash)
1698
1706
  reversibilityProbes.set("Bash", bashReversibilityProbe(undefined, shellReadBoundary));
1699
1707
  if (backgroundTaskToolsActive) {
1700
- shellGatedMonitor = !egressTools.has("Monitor") && !irreversibilityTier.has("Monitor");
1701
- irreversibilityTier.set("Monitor", shellGate === "always" ? "always" : "maybe");
1708
+ const monitorTierBefore = irreversibilityTier.get("Monitor");
1709
+ shellGatedMonitor = !egressTools.has("Monitor") && monitorTierBefore !== "always" && monitorTierBefore !== "maybe";
1710
+ const monitorEffectiveTier = shellGate === "always" || monitorTierBefore === "always" ? "always" : "maybe";
1711
+ irreversibilityTier.set("Monitor", monitorEffectiveTier);
1702
1712
  irreversibleTools.add("Monitor");
1703
- if (shellGate === "classify")
1713
+ if (shellGate === "classify" && shellGatedMonitor)
1704
1714
  reversibilityProbes.set("Monitor", bashReversibilityProbe(undefined, shellReadBoundary));
1705
1715
  }
1706
1716
  }
@@ -1936,7 +1946,21 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1936
1946
  sessionId,
1937
1947
  taskRootPath,
1938
1948
  memoryWriteGateRef,
1939
- writeToolsMounted: tools.some((t) => t.name === "Write") && !(toolFaceSnapshot.exclude?.includes("Write") ?? false),
1949
+ writeToolsMounted: tools.some((t) => t.name === "Write") &&
1950
+ !(toolFaceSnapshot.exclude?.includes("Write") ?? false) &&
1951
+ !(handsEnabled && isRemoteExecutionEnv(executionEnv) && spec.memoryPersistenceCapable !== true),
1952
+ memoryPersistenceDeclared: spec.memoryPersistenceCapable,
1953
+ rosterCanPersist: spec.memoryPersistenceCapable ??
1954
+ tools.some((t) => {
1955
+ if (toolFaceSnapshot.exclude?.includes(t.name) ?? false)
1956
+ return false;
1957
+ const effect = (t.effect ?? toolEffects.get(t.name) ?? "write");
1958
+ if (effect === "read")
1959
+ return false;
1960
+ if (t.name !== "Write" && t.name !== "Edit" && t.name !== "NotebookEdit" && t.name !== "Bash")
1961
+ return false;
1962
+ return !(handsEnabled && isRemoteExecutionEnv(executionEnv));
1963
+ }),
1940
1964
  memorySearchToolsPlanned,
1941
1965
  admissionCtx: {
1942
1966
  orgMemoryDenied: complianceDenies.has("org_memory_mount"),
@@ -3236,9 +3260,25 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3236
3260
  },
3237
3261
  };
3238
3262
  })();
3239
- const ruleSuggestionsOf = (toolName, args) => {
3263
+ const ruleSuggestionsOf = (toolName, args, ask) => {
3240
3264
  if (permissionRuleLane === undefined || toolName !== PERSISTED_RULE_TOOL)
3241
3265
  return {};
3266
+ if ((spec.principal === undefined || spec.principal === "") && deps.localOwnerRules !== true)
3267
+ return {};
3268
+ if (ask?.requiresRealApproval === true ||
3269
+ ask?.persistedRuleShadowed !== undefined ||
3270
+ ask?.decisionReason === "hook" ||
3271
+ ask?.inheritedUnresolved === true ||
3272
+ ask?.ancestorResolved === true) {
3273
+ return {};
3274
+ }
3275
+ if (persistedRuleMandateOf({
3276
+ egress: egressTools.has(toolName),
3277
+ irreversibility: irreversibilityTier.get(toolName),
3278
+ shellGated: shellGatedBash,
3279
+ }) !== undefined) {
3280
+ return {};
3281
+ }
3242
3282
  const command = args?.command;
3243
3283
  if (typeof command !== "string")
3244
3284
  return {};
@@ -3291,7 +3331,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3291
3331
  toolName: creq.toolName,
3292
3332
  toolCallId: creq.toolCallId,
3293
3333
  args: editArgs,
3294
- ...ruleSuggestionsOf(creq.toolName, editArgs),
3334
+ ...ruleSuggestionsOf(creq.toolName, editArgs, { ...(re ?? {}), ancestorResolved: true }),
3295
3335
  message: re.message ?? `approval required for "${creq.toolName}" (inherited parent policy)`,
3296
3336
  ...askSourceIdentity(),
3297
3337
  ...riskAxesOf(creq.toolName),
@@ -3376,7 +3416,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3376
3416
  toolName: creq.toolName,
3377
3417
  toolCallId: creq.toolCallId,
3378
3418
  args: presentedArgs,
3379
- ...ruleSuggestionsOf(creq.toolName, presentedArgs),
3419
+ ...ruleSuggestionsOf(creq.toolName, presentedArgs, { ...(first.action === "ask" ? first : {}), ancestorResolved: true }),
3380
3420
  message: first.message ?? `approval required for "${creq.toolName}" (inherited parent policy)`,
3381
3421
  ...askSourceIdentity(),
3382
3422
  ...riskAxesOf(creq.toolName),
@@ -3463,7 +3503,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3463
3503
  toolName: creq.toolName,
3464
3504
  toolCallId: creq.toolCallId,
3465
3505
  args: presentedArgs,
3466
- ...ruleSuggestionsOf(creq.toolName, presentedArgs),
3506
+ ...ruleSuggestionsOf(creq.toolName, presentedArgs, { ...(decision.action === "ask" ? decision : {}), ancestorResolved: true }),
3467
3507
  message: decision.message ?? `approval required for "${creq.toolName}" (inherited parent policy)`,
3468
3508
  ...askSourceIdentity(),
3469
3509
  ...riskAxesOf(creq.toolName),
@@ -3776,7 +3816,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3776
3816
  const preview = approvalPreviewOf(req.toolName, req.args);
3777
3817
  return preview !== undefined ? { preview } : {};
3778
3818
  })(),
3779
- ...ruleSuggestionsOf(req.toolName, req.args),
3819
+ ...ruleSuggestionsOf(req.toolName, req.args, decision.action === "ask" ? decision : undefined),
3780
3820
  message: decision.message ?? `approval required for "${req.toolName}"`,
3781
3821
  ...askSourceIdentity(),
3782
3822
  ...riskAxesOf(req.toolName),
@@ -4263,7 +4303,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4263
4303
  }
4264
4304
  };
4265
4305
  const suspendAsk = parkLaneArmed && checkpointStore !== undefined
4266
- ? async (req, postHookArgs, safety, liveFaceUnavailable, realApproval, shadowedRule) => {
4306
+ ? async (req, postHookArgs, safety, liveFaceUnavailable, realApproval, shadowedRule, askDecisionReason) => {
4267
4307
  const syncFirstEligible = req.toolName === ASK_USER_QUESTION_TOOL_NAME ? contentAskRoutable(req.toolCallId) : isLiveApproverSeat(onAsk);
4268
4308
  if (syncFirstEligible &&
4269
4309
  runtimeCaps?.forceDurableGate !== true &&
@@ -4418,7 +4458,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4418
4458
  const preview = approvalPreviewOf(req.toolName, parkedArgs);
4419
4459
  return preview !== undefined ? { preview } : {};
4420
4460
  })(),
4421
- ...ruleSuggestionsOf(req.toolName, parkedArgs),
4461
+ ...ruleSuggestionsOf(req.toolName, parkedArgs, {
4462
+ ...(realApproval !== undefined ? { requiresRealApproval: true } : {}),
4463
+ ...(shadowedRule !== undefined ? { persistedRuleShadowed: shadowedRule } : {}),
4464
+ ...(askDecisionReason !== undefined ? { decisionReason: askDecisionReason } : {}),
4465
+ ...(inheritedUnavailableAsks.has(req.toolCallId) ? { inheritedUnresolved: true } : {}),
4466
+ }),
4422
4467
  boundInputHash: boundInputHashOf(parkedArgs),
4423
4468
  batchToolCallIds,
4424
4469
  completedCallIds,
@@ -250,7 +250,14 @@ export declare class Runner {
250
250
  * env_failed re-resume supplied a decision ≠ the persisted winner), `reopened_concurrently` (the
251
251
  * optimistic-concurrency `rev` changed under a concurrent resolve/reopen — re-resume against current
252
252
  * state), `unsupported_version` (checkpoint newer than this worker / remote handle with no factory),
253
- * `already_resolved` (lost the CAS — idempotent no-op).
253
+ * `already_resolved` (lost the CAS — idempotent no-op), `walltime_axis_retired` (pre-CAS: the
254
+ * persisted ledger carries the retired wall-clock budget axis — a worker of the previous release can
255
+ * still finish it), `resume_aborted` (the caller's signal was already aborted at entry, or aborted
256
+ * during the resume — pre-CAS/edit-re-adjudication legs leave the checkpoint pending and resumable,
257
+ * the post-claim leg leaves it consumed; the message says which), `reopen_failed` (an aborted resume
258
+ * needed to reopen the checkpoint and the store refused, or the reopen failed in flight — the message
259
+ * distinguishes terminally-consumed from state-unprovable). Each code's full contract is on
260
+ * {@link CheckpointError.code}.
254
261
  */
255
262
  resume(token: CheckpointToken, outcome: ResumeOutcome, taskConfig: ResumeTaskConfig,
256
263
  /** RB-48② (1.404): same TRUSTED run-internals seam as {@link resumeStream} — the convenience wrapper
@@ -12,5 +12,11 @@ import { type ContractAssertionRunner } from "./contract-harness.js";
12
12
  * with a narrower native key space owns an injective encoding, not a rejection). Before 2.1.0 the
13
13
  * bundled backends genuinely diverged on both halves (dev-green / deployment-red), so running this
14
14
  * kit against a pre-2.1.0-modeled backend is expected to go red on those two entries.
15
+ *
16
+ * Backlog #119 adds the provenance leg: `put`'s third argument and the `ownerOf` read face. `ownerOf`
17
+ * is typed OPTIONAL on the interface (source compatibility for a backend written against an older
18
+ * engine), but this kit REQUIRES it — a backend that cannot say who owns a ref cannot authorize a
19
+ * host-side read face, and an optional parameter one store honors while another drops it silently is
20
+ * exactly the divergence the two legs above exist to prevent.
15
21
  */
16
22
  export declare function toolResultStoreContract(make: () => ToolResultStore, runAssertion?: ContractAssertionRunner): Promise<void>;
@@ -31,5 +31,29 @@ export async function toolResultStoreContract(make, runAssertion) {
31
31
  }
32
32
  assert.equal((await store.get("tr_sess_call:1")).content, "payload-tr_sess_call:1");
33
33
  });
34
+ run("#119 provenance: content+owner are one write; ownerOf answers; a different owner is a TYPED refusal; unowned rows stay unowned", async () => {
35
+ const store = make();
36
+ assert.equal(typeof store.ownerOf, "function", "a backend must implement ownerOf — a store that cannot say who owns a ref cannot back a host read face");
37
+ const ownerOf = (ref) => Promise.resolve(store.ownerOf(ref));
38
+ const ref = "tr_s1~c1";
39
+ await store.put(ref, "0123456789", { sessionId: "sess-A", taskId: "task-1" });
40
+ assert.deepEqual(await ownerOf(ref), { sessionId: "sess-A", taskId: "task-1" }, "the winning write's owner round-trips");
41
+ await store.put(ref, "IGNORED", { sessionId: "sess-A", taskId: "task-1" });
42
+ assert.equal((await store.get(ref)).content, "0123456789");
43
+ assert.deepEqual(await ownerOf(ref), { sessionId: "sess-A", taskId: "task-1" });
44
+ await store.put(ref, "IGNORED");
45
+ assert.deepEqual(await ownerOf(ref), { sessionId: "sess-A", taskId: "task-1" }, "an ownerless put must not clear the owner");
46
+ for (const other of [{ sessionId: "sess-B" }, { sessionId: "sess-A", taskId: "task-2" }]) {
47
+ await assert.rejects((async () => store.put(ref, "other tenant's bytes", other))(), (err) => err.code === "tool_result.ref_conflict", `put(${JSON.stringify(other)}) on an occupied ref must reject with code tool_result.ref_conflict`);
48
+ }
49
+ assert.equal((await store.get(ref)).content, "0123456789", "a refused put must not have overwritten anything");
50
+ const unowned = "tr_no_owner~c";
51
+ await store.put(unowned, "bytes from a write site that stated no owner");
52
+ assert.equal(await ownerOf(unowned), undefined, "a row stored without provenance is UNOWNED");
53
+ await store.put(unowned, "bytes from a write site that stated no owner", { sessionId: "sess-A" });
54
+ assert.equal(await ownerOf(unowned), undefined, "put must not back-fill an owner onto an unowned row");
55
+ assert.equal((await store.get(unowned)).content, "bytes from a write site that stated no owner");
56
+ assert.equal(await ownerOf("tr_never~written"), undefined);
57
+ });
34
58
  await settle();
35
59
  }
@@ -415,9 +415,10 @@ export declare function notFoundRunningAgentsTail(footer: {
415
415
  * so a structured consumer lost the failure FACT at exactly the moment it could no longer reach the
416
416
  * live handle, left to scrape the prose body's `error:` line.
417
417
  *
418
- * The status GATES live here too (killed ⇒ stoppedBy, failed ⇒ error/errorCode/retryable) rather than
419
- * at the call sites: which facts a given status may carry is part of the same contract, and a gate
420
- * copied per face is the same drift with extra steps. `error` is model/provider-influenceable text, so
418
+ * The fact gates live here too (failed ⇒ error/errorCode/retryable/retryAfterMs; `stoppedBy` rides
419
+ * whenever the ROW carries it, on any status a reaper-settled `failed` row keeps its attribution,
420
+ * see the widening note in the builder) rather than at the call sites: which facts a given row may
421
+ * carry is part of the same contract, and a gate copied per face is the same drift with extra steps. `error` is model/provider-influenceable text, so
421
422
  * the fencing + bounding (RB-386②'s posture) happens once, here, for both faces.
422
423
  * The parked projection is this builder with fewer facts, not a third literal. */
423
424
  export interface AgentPollDetailsInput {
@@ -6,7 +6,7 @@ import { shutdownDebug } from "./shutdown-debug.js";
6
6
  import { delimitUntrusted } from "./untrusted-text.js";
7
7
  import { boundedRedactedSummary } from "./untrusted-egress.js";
8
8
  import { mintCompletionId, commitCompletionIdIfEmpty, clipTaskOutput, assertOwnership, sleepPollStep, alreadyTerminalStopNote, canAccess, normalizeAgentName, closestName, DURABLE_AGENT_HEARTBEAT_MS, DURABLE_AGENT_HANDLE_RE, BG_AGENT_REAP_STOP_ERROR, } from "./task-registry-shared.js";
9
- import { buildToolResultRef, OFFLOAD_TOOL_NAME } from "./tool-result-store.js";
9
+ import { buildToolResultRef, OFFLOAD_TOOL_NAME, toolResultProvenanceOf } from "./tool-result-store.js";
10
10
  export function ensureDurableHeartbeatLane(core) {
11
11
  if (core.durableHeartbeatTimer !== undefined)
12
12
  return;
@@ -1232,8 +1232,8 @@ export async function spillClippedAgentResult(handle, full, clipped, store, sess
1232
1232
  if (store === undefined)
1233
1233
  return clipped;
1234
1234
  if (handle.spillRef === undefined) {
1235
- const ref = buildToolResultRef(sessionId ?? "no-session", `${handle.id}_c${handle.reviveCycle ?? 0}`);
1236
- await store.put(ref, full);
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
1237
  handle.spillRef = ref;
1238
1238
  }
1239
1239
  return `${clipped}\n\n[full output persisted — call ${OFFLOAD_TOOL_NAME} with ref "${handle.spillRef}" to read it back.]`;
@@ -1,6 +1,6 @@
1
1
  import { delimitUntrusted } from "./untrusted-text.js";
2
2
  import { assertOwnership, defaultMonitorTimers, MONITOR_MAX_TIMEOUT_MS, MONITOR_DEFAULT_TIMEOUT_MS, MONITOR_BATCH_WINDOW_MS, MONITOR_MAX_BATCHES_PER_MINUTE, MONITOR_STORM_BURST, MONITOR_STORM_KILL_AFTER_MS, MONITOR_LINE_BUF_CAP, MONITOR_SPILL_CAP_CHARS, TASK_OUTPUT_MAX_CHARS, mintCompletionId, clipMonitorEvent, clipMonitorLine, terminalTaskSummary, accountDroppedBytes, renderSpoolBody, rollSpoolText, statusFromBackground, droppedGapNote, firstDropNote, alreadyTerminalStopNote, clipTaskOutput, sleepPollStep, } from "./task-registry-shared.js";
3
- import { buildToolResultRef, OFFLOAD_TOOL_NAME } from "./tool-result-store.js";
3
+ import { buildToolResultRef, OFFLOAD_TOOL_NAME, toolResultProvenanceOf } from "./tool-result-store.js";
4
4
  export function registerMonitorLane(core, input) {
5
5
  assertOwnership(input, "registerMonitor");
6
6
  const id = core.mintTaskId("monitor");
@@ -61,14 +61,15 @@ function spillRolledMonitorChunk(handle, stream, dropped) {
61
61
  return;
62
62
  }
63
63
  const n = stream === "out" ? (handle.spillSegCount ?? 0) : (handle.spillErrSegCount ?? 0);
64
- const ref = buildToolResultRef(handle.spillSessionId ?? "no-session", `${handle.id}_${stream}_seg${n}`);
64
+ const ref = buildToolResultRef(handle.spillSessionId ?? "no-session", handle.id, `${stream}_seg${n}`);
65
65
  handle.spillCharsUsed = used + dropped.length;
66
66
  if (stream === "out")
67
67
  handle.spillSegCount = n + 1;
68
68
  else
69
69
  handle.spillErrSegCount = n + 1;
70
70
  try {
71
- void Promise.resolve(store.put(ref, dropped)).catch(() => {
71
+ const provenance = handle.spillSessionId === undefined ? undefined : toolResultProvenanceOf(handle.spillSessionId, handle.id);
72
+ void Promise.resolve(store.put(ref, dropped, provenance)).catch(() => {
72
73
  handle.spillFailed = true;
73
74
  });
74
75
  }
@@ -83,8 +84,8 @@ function monitorSpillNote(handle) {
83
84
  return "";
84
85
  const sid = handle.spillSessionId ?? "no-session";
85
86
  const segLabel = (stream, n) => {
86
- const first = buildToolResultRef(sid, `${handle.id}_${stream}_seg0`);
87
- return n <= 1 ? `ref "${first}"` : `refs "${first}" .. "${buildToolResultRef(sid, `${handle.id}_${stream}_seg${n - 1}`)}"`;
87
+ const first = buildToolResultRef(sid, handle.id, `${stream}_seg0`);
88
+ return n <= 1 ? `ref "${first}"` : `refs "${first}" .. "${buildToolResultRef(sid, handle.id, `${stream}_seg${n - 1}`)}"`;
88
89
  };
89
90
  const clauses = [];
90
91
  if (outN > 0)
@@ -33,8 +33,10 @@ export interface RegisterBackgroundBashInput extends TaskAccess {
33
33
  /** CC 2.1.209 对齐批A A5: the task's OUTPUT FILE — the launch receipt advertises it and the watcher
34
34
  * mirrors every polled increment into it (append-only, unbounded — unlike the rolling in-memory
35
35
  * spool), so "Read the output file path" is a real alternative to TaskOutput. Terminal
36
- * task-notifications carry the same path (`output_file`). Only meaningful with `onTerminal` (the
37
- * watcher is the writer); callers create the file (empty) before registering. */
36
+ * task-notifications carry the same path (`output_file`). Primarily meaningful with `onTerminal`
37
+ * (only the watcher mirrors increments live); a watcher-less row still gets ONE final best-effort
38
+ * append at TaskStop/run-teardown (the tail drain), so the file is written even without a watcher —
39
+ * there is just no live mirror. Callers create the file (empty) before registering. */
38
40
  outputFile?: string;
39
41
  }
40
42
  export interface TaskPollOptions {
@@ -369,7 +371,8 @@ export declare class TaskRegistry {
369
371
  * BEFORE the abort()/status flip — the ordering is load-bearing: the guard below refuses markers on a
370
372
  * non-running handle, so a caller that flips first loses its claim and attribution falls back to
371
373
  * "system". First-marker-wins: an earlier marker (e.g. a service-wire "user") is never overwritten.
372
- * Applies to background_agent AND background_bash (same class); workflow cancellation is out of scope. */
374
+ * Applies to background_agent, background_bash AND monitor handles (the markable kinds the monitor
375
+ * watcher/stop lanes mark and read it too); workflow cancellation is out of scope. */
373
376
  markStopSource(id: string, source: StopSource): void;
374
377
  /**
375
378
  * [1712] / RB-164 — attribute an ENV-LEVEL blanket sweep before it runs.
@@ -632,9 +632,16 @@ export interface AskRequest {
632
632
  /**
633
633
  * design/179 §4 — the persistable allow-rule forms this exact call could be covered by, so a surface can
634
634
  * offer "allow, and stop asking me this" with something concrete behind it. Present only when a
635
- * persisted allow-rule lane is armed AND the call is one the lane can speak for: a compound, a
635
+ * persisted allow-rule lane is armed AND the call is one the lane can speak for (a compound, a
636
636
  * redirection or a substitution yields NO suggestion, which is the honest answer rather than an option
637
- * that would be refused on redemption.
637
+ * that would be refused on redemption) AND the ask is one a persisted rule could actually clear — a
638
+ * mandated ask (operator shellGate:"always", the tool's own egress/irreversibility marks, a
639
+ * `requiresRealApproval` demand) and an ask carrying {@link persistedRuleShadowed} offer none.
640
+ *
641
+ * CONTRACT — array order is display order, narrowest first: the EXACT form is always index 0, a
642
+ * broader reviewed PREFIX form (at most one) follows. Basis ≤ 2. Selection indices and redemption
643
+ * tickets are keyed against this order, and the durable park row carries the same array under the
644
+ * same contract.
638
645
  *
639
646
  * ADVISORY display metadata, never adjudication input, and never a rule by itself: minting one is a
640
647
  * separate act that goes through the approval-record protocol, so a surface that ignores this field
@@ -10,7 +10,7 @@ import { type ToolResultStore } from "./tool-result-store.js";
10
10
  * **Simpler than CC by construction (§24.2):** a request-only, non-destructive transform (returns a new
11
11
  * array; the durable session keeps full results) applied in the existing `harness.on("context")` hook —
12
12
  * the same per-query point as `clearStaleToolResults` (the mirror of CC's query.ts:379). It is DETERMINISTIC
13
- * (stable `ref = tr_<sessionId>_<toolCallId>` + deterministic {@link buildPreview}), so re-running it every
13
+ * (stable `ref = tr_<sessionId>~<toolCallId>` + deterministic {@link buildPreview}), so re-running it every
14
14
  * query yields byte-identical previews → prompt-cache safe **without** CC's ContentReplacementState freeze
15
15
  * machine (the determinism IS the freeze), and resume-free (the transcript holds originals; this re-applies
16
16
  * on replay). Reuses design/30's offload store + preview format; with no store it falls back to a
@@ -1,5 +1,5 @@
1
1
  import { isToolResult } from "./message-utils.js";
2
- import { buildPreview, buildToolResultRef, OFFLOAD_TOOL_NAME, PERSISTED_OUTPUT_PREFIX } from "./tool-result-store.js";
2
+ import { buildPreview, buildToolResultRef, OFFLOAD_TOOL_NAME, PERSISTED_OUTPUT_PREFIX, toolResultContentSegment, toolResultProvenanceOf, } from "./tool-result-store.js";
3
3
  export const AGGREGATE_TOOL_RESULT_BUDGET_CHARS = 200_000;
4
4
  const HEAD = 1_000;
5
5
  const TAIL = 1_000;
@@ -61,13 +61,13 @@ export async function capAggregateToolResults(messages, opts) {
61
61
  const previewOne = async (k, before, head, tail, fromOriginal) => {
62
62
  const m = out[k];
63
63
  const full = textOf((fromOriginal ? messages[k] : m).content);
64
- const ref = buildToolResultRef(opts.sessionId, m.toolCallId ?? `idx${k}`);
64
+ const ref = buildToolResultRef(opts.sessionId, m.toolCallId ?? `idx${k}`, toolResultContentSegment(full));
65
65
  const sizes = head === HEAD && tail === TAIL ? undefined : { head, tail };
66
66
  let previewText;
67
67
  let storeFallback;
68
68
  if (opts.store) {
69
69
  try {
70
- await opts.store.put(ref, full);
70
+ await opts.store.put(ref, full, toolResultProvenanceOf(opts.sessionId));
71
71
  previewText = buildPreview(full, ref, sizes);
72
72
  storeFallback = false;
73
73
  }
@@ -28,14 +28,89 @@ export interface ToolResultStore {
28
28
  * durable store still owns key hygiene for its storage layer). RB-266: the floor of that hygiene is
29
29
  * NOT per-backend — every implementation rejects the unsafe ref set via {@link assertSafeToolResultRef}
30
30
  * (a volatile backend silently accepting what a durable one rejects is the drift this closes).
31
+ *
32
+ * `provenance` (backlog #119, 2026-08-10) states WHO the stored bytes belong to, so a host-side read
33
+ * face can authorize a fetch without parsing the ref. Its rules, uniform on every backend:
34
+ * - **the write-once election decides the owner too** — whoever wins the race to store the content
35
+ * is the owner, and no later writer can change that. A backend that publishes content and owner
36
+ * as two objects (the file one does) may, if it is interrupted between them, leave an entry
37
+ * content-complete and UNOWNED; that is the fail-closed direction (a read face refuses it) and it
38
+ * is never mis-attribution, but it does mean "unowned" is a reachable state, not only a
39
+ * pre-provenance one. A single-row backend (memory/SQL) has no such window;
40
+ * - a later `put` on the same ref carrying the **same** provenance (or none) is the usual idempotent
41
+ * no-op — the stored owner is neither re-written nor erased;
42
+ * - a later `put` carrying a **different** provenance is a typed refusal
43
+ * ({@link ToolResultRefConflictError}, `code: "tool_result.ref_conflict"`), never a silent no-op:
44
+ * `ref` is the storage PRIMARY KEY, and with a host-supplied `sessionId` two distinct owners can
45
+ * land on one ref — under a silent write-once the second owner would read back the first owner's
46
+ * bytes, which is an identity fault, not a caching detail;
47
+ * - provenance must never be FINER-GRAINED than the ref's own key. A ref keyed on
48
+ * (session, tool call) is legitimately written by two different tasks of one session — the
49
+ * Runner shares one store across its tasks on purpose, and a BYOM brain may mint the same
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;
53
+ * - an entry stored **without** provenance (written by an older engine, or by a call site that has
54
+ * none) stays UNOWNED: `put` never back-fills an owner onto it (that would be adoption by a party
55
+ * that cannot prove it wrote it) and {@link ownerOf} keeps answering `undefined`, which a read face
56
+ * reads fail-closed. Back-filling such rows is an explicit migration, never a side effect of a write.
31
57
  */
32
- put(ref: string, content: string): Promise<void> | void;
58
+ put(ref: string, content: string, provenance?: ToolResultProvenance): Promise<void> | void;
33
59
  /** Read a slice of the stored content. Unknown `ref` → undefined. */
34
60
  get(ref: string, opts?: {
35
61
  offset?: number;
36
62
  limit?: number;
37
63
  }): Promise<ToolResultSlice | undefined> | ToolResultSlice | undefined;
64
+ /**
65
+ * Backlog #119 — the READ-side half of provenance: who owns `ref`. `undefined` for an unknown ref AND
66
+ * for a stored-but-unowned one (a row whose write stated no owner), which a read face treats identically:
67
+ * fail-closed, nobody is authorized. Without this operation a `put`-only provenance parameter would be
68
+ * write-only bookkeeping — the read face would still have nothing to decide on.
69
+ *
70
+ * Typed OPTIONAL so an implementation compiled against an older engine still satisfies the interface;
71
+ * the published contract kit (`toolResultStoreContract`) nevertheless REQUIRES it, because a backend
72
+ * that cannot answer this cannot back a host read face at all.
73
+ */
74
+ ownerOf?(ref: string): Promise<ToolResultProvenance | undefined> | ToolResultProvenance | undefined;
75
+ }
76
+ /**
77
+ * Backlog #119 — WHO a stored tool result belongs to. Structured (not a single opaque string) because
78
+ * the read face authorizes by COMPARING fields it already knows; `taskId` is present when the write site
79
+ * has one (it is a narrowing of the session, never a substitute for it).
80
+ *
81
+ * Deliberately NOT part of it: `toolCallId`. It is a retention/addressing coordinate, not an ownership
82
+ * one — two different tool calls in one session are the same owner.
83
+ */
84
+ export interface ToolResultProvenance {
85
+ readonly sessionId: string;
86
+ readonly taskId?: string;
38
87
  }
88
+ /** Backlog #119 — the `code` on {@link ToolResultRefConflictError}, so callers branch on a constant. */
89
+ export declare const TOOL_RESULT_REF_CONFLICT_CODE = "tool_result.ref_conflict";
90
+ /**
91
+ * Backlog #119 — a `put` whose provenance disagrees with the stored one. Typed (a `code`, like
92
+ * `CheckpointError`/`WorkflowRunStoreError`) rather than a silent no-op: this is the one write-once
93
+ * outcome where "keep what is there" would hand the second caller a reference to somebody else's bytes.
94
+ */
95
+ export declare class ToolResultRefConflictError extends Error {
96
+ readonly ref: string;
97
+ readonly stored: ToolResultProvenance;
98
+ readonly incoming: ToolResultProvenance;
99
+ readonly code = "tool_result.ref_conflict";
100
+ constructor(ref: string, stored: ToolResultProvenance, incoming: ToolResultProvenance);
101
+ }
102
+ /**
103
+ * Backlog #119 — the ONE comparison every backend uses, so "same owner" cannot mean two things in two
104
+ * stores. A missing PROVENANCE on either side ⇒ no verdict (an unowned entry is never adopted, and a
105
+ * put with no provenance never disturbs a stored owner). Both present ⇒ every field must agree,
106
+ * including `taskId`: a missing task id is a DIFFERENT owner from a stated one, not a wildcard that
107
+ * matches it — two writers that disagree about how narrowly they own a key have not proven they are
108
+ * the same writer. Unequal ⇒ {@link ToolResultRefConflictError}.
109
+ */
110
+ export declare function assertToolResultProvenanceMatch(ref: string, stored: ToolResultProvenance | undefined, incoming: ToolResultProvenance | undefined): void;
111
+ /** Normalize to the stored shape: an ABSENT `taskId` and an explicit `undefined` one are one value, so
112
+ * round-tripping through a durable backend (NULL column, missing JSON key) compares equal. */
113
+ export declare function normalizeToolResultProvenance(p: ToolResultProvenance): ToolResultProvenance;
39
114
  /**
40
115
  * RB-266 — the ref-safety half of the {@link ToolResultStore} contract, shared by every backend.
41
116
  *
@@ -64,7 +139,7 @@ export interface ToolResultStore {
64
139
  export declare function assertSafeToolResultRef(ref: string): void;
65
140
  /**
66
141
  * RB-273 — the SINGLE mint point for a tool-result `ref`. Three call sites compose the same
67
- * `tr_<sessionId>_<toolCallId>` string (`withToolResultOffload` here, the aggregate budget's preview
142
+ * `tr_<sessionId>~<toolCallId>` string (`withToolResultOffload` here, the aggregate budget's preview
68
143
  * pass, and the runner's clear-with-offload persist); this is that string's one definition.
69
144
  *
70
145
  * Why it must sanitize: `sessionId` is engine-minted, but **`toolCallId` is PROVIDER-minted** — it is
@@ -87,8 +162,76 @@ export declare function assertSafeToolResultRef(ref: string): void;
87
162
  * and an unsafe one only ever lived in a memory/pg deployment, where the ref the model reads back comes
88
163
  * from the `<persisted-output ref="…">` text already in its context — the read path does not re-mint,
89
164
  * and `get` is keyed on that literal string.
165
+ *
166
+ * ── Backlog #119 (2026-08-10): the composition is now INJECTIVE ───────────────────────────────────
167
+ * It was not. With `_` between the segments — a character legal INSIDE both — `tr_team_blue_x`
168
+ * decomposed as ("team","blue_x") and as ("team_blue","x"), i.e. two distinct (session, tool call)
169
+ * pairs minted one ref. `ref` is the store's PRIMARY KEY under a write-once contract, so the second
170
+ * pair did not get an error, it got the FIRST pair's bytes back — and `sessionId` is host-supplied in a
171
+ * BYOM deployment, so the two pairs need not be the same tenant. (Measured on the published artifact:
172
+ * ("s","1_a_b") and ("s_1","a_b") produced the same ref.)
173
+ *
174
+ * The separator is now {@link REF_SEGMENT_SEPARATOR} — a character OUTSIDE {@link NATIVE_REF_CHARSET}.
175
+ * `refSegment` emits only characters from that charset (identity branch by test; fold branch by
176
+ * construction: a readable base with every out-of-charset char replaced, plus hex), so the separator
177
+ * can never occur inside a segment and `tr_<seg>~<seg>` decomposes exactly one way. Injectivity of the
178
+ * whole map then follows from `refSegment`'s own injectivity per segment.
179
+ *
180
+ * Why this is NOT a breaking change, spelled out because it looks like one:
181
+ * - refs are OPAQUE HANDLES. A ref reaches the model inside the preview text and comes back verbatim
182
+ * to `ReadToolResult`; `get` is keyed on that literal string and no read path re-mints;
183
+ * - so an entry written under the old form stays readable forever: an old ref in an old context reads
184
+ * back byte-for-byte, on every backend (the file backend's filename encoding is likewise keyed on
185
+ * the literal ref, and is identity for the old form);
186
+ * - the mint is deterministic per process, so a ref MINTED here is also RESOLVED here — there is no
187
+ * window in which one process writes `~` and another expects `_` for the same pair. The upgrade
188
+ * boundary is a process restart, and it needs no double-read window;
189
+ * - the only observable change is the ref STRING for newly offloaded results, which no wire contract
190
+ * pins (it is generated text, and the length ceiling downstream validators apply is unchanged).
191
+ * The store-key charset is unchanged as far as the shared rule is concerned ({@link assertSafeToolResultRef}
192
+ * accepts `~`: it is not empty/dot/separator/control). A backend whose NATIVE key space excludes `~`
193
+ * (the file one's filename charset) already owns an injective encoding into it — the RB-273 rule — so
194
+ * for that backend this simply moves engine refs from the identity branch to the digest branch.
195
+ *
196
+ * `extraSegments` (also #119) is how a call site that addresses something FINER than one tool call —
197
+ * a `details` member's JSON path, a stale-projection content digest — keeps that coordinate in its own
198
+ * segment instead of splicing it into the `toolCallId` one. Splicing was the ticket's other residue:
199
+ * a ref built from the compound `"<callId> <path>"` is exactly the ref another call would mint if its
200
+ * provider-assigned toolCallId happened to BE that whole string. With the coordinate in its own
201
+ * segment the two can no longer coincide — the separator cannot occur inside a segment, so refs with
202
+ * different segment COUNTS are always distinct, and same-count refs are distinct unless every segment
203
+ * matches.
204
+ */
205
+ export declare function buildToolResultRef(sessionId: string, toolCallId: string, ...extraSegments: string[]): string;
206
+ /**
207
+ * Backlog #119 — the ceiling on a ref this module can mint, so a durable backend can size its key
208
+ * column from a stated invariant instead of a guess (TiDB's was `VARCHAR(191)`, which a long
209
+ * three-segment ref overruns — a truncating key aliases distinct refs).
210
+ *
211
+ * Derivation: `"tr_"` (3) + four segments at their own ceiling + three separators. A segment is either
212
+ * an identity one (≤ {@link MAX_REF_SEGMENT_CHARS}) or a folded one (marker + ≤32 base + `-` + 64 hex
213
+ * = 102), so {@link MAX_REF_SEGMENT_CHARS} is the larger. Four segments is the engine's own widest
214
+ * shape (session + tool call + a `details` path + the content coordinate); `buildToolResultRef` is
215
+ * variadic, so a caller that passes more owns the wider bound.
216
+ */
217
+ export declare const MAX_MINTED_TOOL_RESULT_REF_CHARS: number;
218
+ /**
219
+ * Backlog #119 (adversarial review, round 2) — the CONTENT coordinate of an offload ref.
220
+ *
221
+ * A tool-call id carries no cross-turn uniqueness contract: a BYOM brain legitimately mints recurring
222
+ * ids like `call-1`, and the Runner shares one store across a session's tasks on purpose. Keyed on
223
+ * (session, tool call) alone, the second result under a recurring id hits a write-once no-op — a
224
+ * SUCCESSFUL put that stored nothing — while its own preview goes out naming that ref, so paging it
225
+ * back returns the FIRST result's bytes. Provenance cannot see this (both writers are the same
226
+ * session, and they legitimately are), so the ref itself has to carry content identity.
227
+ *
228
+ * 128 bits, the same width and the same reasoning as the stale-result projection that closed this in
229
+ * its own lane earlier: tool results are untrusted text (web content rides them), and a 48-bit
230
+ * truncation is birthday-collidable at ~2^24 work — a deliberate collision plus a recurring id would
231
+ * re-open exactly the cross-serving this closes. Deterministic, so a re-derived ref for identical
232
+ * content is byte-identical and the preview stays prompt-cache stable.
90
233
  */
91
- export declare function buildToolResultRef(sessionId: string, toolCallId: string): string;
234
+ export declare function toolResultContentSegment(text: string): string;
92
235
  export interface ToolResultSlice {
93
236
  content: string;
94
237
  /** Byte/char offset this slice starts at. */
@@ -108,12 +251,17 @@ export declare class InMemoryToolResultStore implements ToolResultStore {
108
251
  private readonly opts?;
109
252
  /** put/get only — no scheduled deletion contract: honestly `"none"` (retention.ts). */
110
253
  readonly retention: "none";
254
+ /** #119: the entry is content AND owner as ONE value — the write-once election that decides the
255
+ * content decides the owner in the same step (no window where an entry exists ownerless). */
111
256
  private readonly map;
112
257
  private totalChars;
113
258
  constructor(opts?: {
114
259
  maxTotalChars?: number;
115
260
  } | undefined);
116
- put(ref: string, content: string): void;
261
+ put(ref: string, content: string, provenance?: ToolResultProvenance): void;
262
+ /** #119 — the owner recorded at the winning write; `undefined` for unknown AND for unowned entries,
263
+ * which a read face treats identically (fail-closed). */
264
+ ownerOf(ref: string): ToolResultProvenance | undefined;
117
265
  /** design/80 D-2: true when NOTHING has been offloaded — a durable suspend can then proceed safely even on
118
266
  * this in-memory store, because a cross-replica resume has no offloaded result to deref to null. */
119
267
  isEmpty(): boolean;
@@ -147,15 +295,18 @@ export declare class ScopedToolResultStore implements ToolResultStore {
147
295
  readonly volatileBacking: boolean;
148
296
  constructor(inner: ToolResultStore, scope: string);
149
297
  /** Length-prefixed namespace — unambiguous for any scope string (no delimiter-injection ambiguity).
150
- * RB-266: the scope segment is percent-ENCODED (injective, and its output charset carries no path
151
- * separator or control character), so a deployment whose trust scope contains one cannot compose a
152
- * key the inner store must reject — the ref-safety rule then applies to the caller's ref alone. */
298
+ * RB-266: the scope segment is ENCODED (injective, and its output charset carries no path separator
299
+ * or control character), so a deployment whose trust scope contains one cannot compose a key the
300
+ * inner store must reject — the ref-safety rule then applies to the caller's ref alone. */
153
301
  private key;
154
- put(ref: string, content: string): Promise<void> | void;
302
+ put(ref: string, content: string, provenance?: ToolResultProvenance): Promise<void> | void;
155
303
  get(ref: string, opts?: {
156
304
  offset?: number;
157
305
  limit?: number;
158
306
  }): Promise<ToolResultSlice | undefined> | ToolResultSlice | undefined;
307
+ /** #119 — same namespacing as {@link get}. `undefined` when the inner store predates the operation
308
+ * (an unowned answer, which the read face reads fail-closed — never a fabricated owner). */
309
+ ownerOf(ref: string): Promise<ToolResultProvenance | undefined> | ToolResultProvenance | undefined;
159
310
  /** THIS task's offload count only — the D-2 gate's per-task semantics (see class doc). */
160
311
  isEmpty(): boolean;
161
312
  }
@@ -171,6 +322,10 @@ export declare const OFFLOAD_TOOL_NAME = "ReadToolResult";
171
322
  * "expired" are different answers). If the marker write fails too, the generic miss remains.
172
323
  */
173
324
  export declare function createOffloadPersist(store: ToolResultStore, sessionId: string): (toolCallId: string, fullText: string) => string;
325
+ /** Backlog #119 — build the provenance a write site records, from the two coordinates every write site
326
+ * already has in hand. `taskId` is omitted (not `undefined`-valued) when the run declares none, so the
327
+ * stored shape compares equal across a durable round-trip. */
328
+ export declare function toolResultProvenanceOf(sessionId: string, taskId?: string): ToolResultProvenance;
174
329
  /**
175
330
  * RB-374① — the ONE source of the "page the offloaded text back" suggestion sentence, shared by
176
331
  * {@link buildPreview} (offload preview tail) and context-edit's cleared-marker refNote. The two
@@ -228,7 +383,7 @@ export declare function buildPreview(full: string, ref: string, sizes?: {
228
383
  }, reachableTools?: ReadonlySet<string>): string;
229
384
  /**
230
385
  * Wrap an {@link AgentTool} so that an oversized text result is offloaded to `store` and replaced with a
231
- * preview + `ref` before it ever reaches the session/model. `ref = tr_<toolCallId>` is stable and the
386
+ * preview + `ref` before it ever reaches the session/model. `ref = tr_<sessionId>~<toolCallId>` is stable and the
232
387
  * store is write-once: a **same-process** replay keeps an identical (cacheable) preview; a **cross-process**
233
388
  * wake needs a durable store (the default in-memory store loses the full text — the preview still stands).
234
389
  * Image blocks are left untouched; only text is offloaded. Forwards the `onUpdate` progress callback so a