@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.
- package/CHANGELOG.md +115 -0
- package/dist/agents/agent-definition.js +5 -0
- package/dist/agents/agent-transcript-tool.d.ts +5 -2
- package/dist/agents/agent-transcript-tool.js +2 -1
- package/dist/agents/send-message-tool.d.ts +4 -1
- package/dist/agents/send-message-tool.js +1 -0
- package/dist/agents/subagent.d.ts +6 -2
- package/dist/agents/subagent.js +5 -0
- package/dist/core/checkpoint-store.d.ts +7 -2
- package/dist/core/hooks.d.ts +39 -4
- package/dist/core/hooks.js +18 -14
- package/dist/core/memory-engine/dual-root.js +3 -1
- package/dist/core/memory-engine/engine.d.ts +53 -6
- package/dist/core/memory-engine/engine.js +43 -11
- package/dist/core/memory-engine/file-backend.d.ts +81 -0
- package/dist/core/memory-engine/file-backend.js +250 -24
- package/dist/core/memory-engine/index.d.ts +1 -1
- package/dist/core/memory-engine/index.js +1 -1
- package/dist/core/memory-engine/types.d.ts +8 -1
- package/dist/core/memory-vector.d.ts +6 -1
- package/dist/core/memory-vector.js +14 -4
- package/dist/core/memory.js +1 -6
- package/dist/core/permission-rule-consent.js +8 -1
- package/dist/core/permission-rule-model.d.ts +70 -5
- package/dist/core/permission-rule-model.js +58 -0
- package/dist/core/runner/compaction-call-options.d.ts +4 -4
- package/dist/core/runner/compaction-call-options.js +3 -4
- package/dist/core/runner/prepare-memory.d.ts +34 -15
- package/dist/core/runner/prepare-memory.js +99 -26
- package/dist/core/runner/prepare-task.d.ts +9 -3
- package/dist/core/runner/prepare-task.js +60 -15
- package/dist/core/runner/runtask.d.ts +8 -1
- package/dist/core/store-contracts/tool-result-store-contract.d.ts +6 -0
- package/dist/core/store-contracts/tool-result-store-contract.js +24 -0
- package/dist/core/task-registry-agent.d.ts +4 -3
- package/dist/core/task-registry-agent.js +3 -3
- package/dist/core/task-registry-monitor.js +6 -5
- package/dist/core/task-registry.d.ts +6 -3
- package/dist/core/tool-policy.d.ts +9 -2
- package/dist/core/tool-result-budget.d.ts +1 -1
- package/dist/core/tool-result-budget.js +3 -3
- package/dist/core/tool-result-store.d.ts +164 -9
- package/dist/core/tool-result-store.js +82 -23
- package/dist/core/types.d.ts +103 -7
- package/dist/core/untrusted-text.d.ts +6 -2
- package/dist/core/untrusted-text.js +1 -1
- package/dist/engine/loop/types.d.ts +10 -3
- package/dist/engine/session/import-validate.js +2 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/orchestration/run-workflow-tool.d.ts +5 -3
- package/dist/orchestration/workflow.d.ts +9 -6
- package/dist/orchestration/workflow.js +2 -0
- package/dist/prompts/default.d.ts +11 -0
- package/dist/prompts/default.js +3 -0
- package/dist/stores/file/checkpoint-store.d.ts +2 -1
- package/dist/stores/file/fs-atomic.d.ts +1 -1
- package/dist/stores/file/index.d.ts +1 -1
- package/dist/stores/file/tool-result-store.d.ts +45 -9
- package/dist/stores/file/tool-result-store.js +76 -9
- package/dist/tools/fs/fs-shared.js +5 -4
- 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
|
-
|
|
1691
|
-
|
|
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
|
-
|
|
1701
|
-
|
|
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") &&
|
|
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
|
|
419
|
-
*
|
|
420
|
-
*
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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,
|
|
87
|
-
return n <= 1 ? `ref "${first}"` : `refs "${first}" .. "${buildToolResultRef(sid,
|
|
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`).
|
|
37
|
-
* watcher
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|