@sema-agent/core 2.3.0 → 2.4.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 (61) hide show
  1. package/dist/agents/send-message-tool.d.ts +4 -0
  2. package/dist/agents/send-message-tool.js +37 -24
  3. package/dist/agents/subagent.js +275 -127
  4. package/dist/brain/errors.d.ts +1 -0
  5. package/dist/brain/errors.js +14 -0
  6. package/dist/brain/stream-engine.js +3 -3
  7. package/dist/core/context-edit.js +2 -1
  8. package/dist/core/runner/prepare-task.js +20 -1
  9. package/dist/core/runner/tool-output-projection.js +2 -1
  10. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +37 -0
  11. package/dist/core/store-contracts/checkpoint-store-contract.js +195 -0
  12. package/dist/core/store-contracts/contract-harness.d.ts +6 -0
  13. package/dist/core/store-contracts/contract-harness.js +16 -0
  14. package/dist/core/store-contracts/contract-kit-version.d.ts +1 -0
  15. package/dist/core/store-contracts/contract-kit-version.js +2 -0
  16. package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +3 -0
  17. package/dist/core/store-contracts/file-snapshot-store-contract.js +126 -0
  18. package/dist/core/store-contracts/mailbox-store-contract.d.ts +6 -0
  19. package/dist/core/store-contracts/mailbox-store-contract.js +193 -0
  20. package/dist/core/store-contracts/session-repo-contract.d.ts +3 -0
  21. package/dist/core/store-contracts/session-repo-contract.js +36 -0
  22. package/dist/core/store-contracts/tool-result-store-contract.d.ts +3 -0
  23. package/dist/core/store-contracts/tool-result-store-contract.js +35 -0
  24. package/dist/core/task-notification.d.ts +2 -0
  25. package/dist/core/task-registry-agent.d.ts +4 -0
  26. package/dist/core/task-registry-agent.js +13 -0
  27. package/dist/core/task-registry-monitor.js +6 -6
  28. package/dist/core/task-registry-shared.d.ts +8 -2
  29. package/dist/core/task-registry-shared.js +1 -1
  30. package/dist/core/task-registry.d.ts +6 -0
  31. package/dist/core/task-registry.js +48 -4
  32. package/dist/core/tool-result-store.d.ts +3 -2
  33. package/dist/core/tool-result-store.js +12 -4
  34. package/dist/core/trace.d.ts +7 -0
  35. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
  36. package/dist/engine/lsp/node-lsp-manager.js +16 -0
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.js +1 -0
  39. package/dist/orchestration/builtin-workflows.d.ts +1 -1
  40. package/dist/orchestration/builtin-workflows.js +11 -2
  41. package/dist/orchestration/workflow-governance.d.ts +6 -1
  42. package/dist/orchestration/workflow-governance.js +24 -4
  43. package/dist/orchestration/workflow-primitives.js +7 -1
  44. package/dist/orchestration/workflow.d.ts +1 -0
  45. package/dist/orchestration/workflow.js +31 -2
  46. package/dist/tools/fs/fs-bash.d.ts +7 -1
  47. package/dist/tools/fs/fs-bash.js +51 -20
  48. package/dist/tools/fs/fs-read.js +22 -11
  49. package/dist/tools/fs/fs-search-tools.js +3 -3
  50. package/dist/tools/fs/fs-shared.d.ts +20 -7
  51. package/dist/tools/fs/fs-shared.js +17 -3
  52. package/dist/tools/fs/fs-write.js +4 -4
  53. package/dist/tools/fs/index.d.ts +2 -0
  54. package/dist/tools/fs/index.js +7 -1
  55. package/dist/tools/fs/repo-map.js +2 -2
  56. package/dist/tools/fs/safety.d.ts +10 -0
  57. package/dist/tools/fs/safety.js +15 -1
  58. package/dist/tools/monitor.js +18 -4
  59. package/dist/tools/web.js +6 -2
  60. package/dist/tools/worktree.js +46 -25
  61. package/package.json +1 -1
@@ -0,0 +1,193 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { beginContract } from "./contract-harness.js";
3
+ export const MAILBOX_CONTRACT_SCOPE = "default";
4
+ const msg = (content, sentAt = 1000) => ({ content, sentAt });
5
+ async function withStores(mk, fn) {
6
+ const live = [];
7
+ const make = () => {
8
+ const s = mk();
9
+ live.push(s);
10
+ return s;
11
+ };
12
+ try {
13
+ await fn(make);
14
+ }
15
+ finally {
16
+ for (const s of live.splice(0))
17
+ s.close?.();
18
+ }
19
+ }
20
+ export async function mailboxStoreContract(mk, runAssertion) {
21
+ const { run: runRaw, settle } = beginContract(runAssertion);
22
+ const run = (name, fn) => runRaw(name, () => withStores(mk, fn));
23
+ const S = MAILBOX_CONTRACT_SCOPE;
24
+ run("append 铸单调 seq;claimLease 取全部可见消息(不删);ack 才删;活租约=单消费者", async (make) => {
25
+ const s = make();
26
+ assert.equal(await s.append(S, "a1", msg("m1")), 1);
27
+ assert.equal(await s.append(S, "a1", msg("m2")), 2);
28
+ assert.equal(await s.peekCount(S, "a1"), 2);
29
+ const lease = await s.claimLease(S, "a1", "w1", 60_000, 10_000);
30
+ assert.deepEqual(lease.messages.map((m) => m.content), ["m1", "m2"]);
31
+ assert.equal(lease.maxSeq, 2);
32
+ assert.equal(await s.claimLease(S, "a1", "w2", 60_000, 20_000), null);
33
+ assert.equal(await s.peekCount(S, "a1"), 2);
34
+ await s.ack(S, "a1", "w1", 2);
35
+ assert.equal(await s.peekCount(S, "a1"), 0);
36
+ });
37
+ run("X-1 无损:lease 过期/release 后消息按原 seq 回可见;seq 在箱的一生中永不复用", async (make) => {
38
+ const s = make();
39
+ await s.append(S, "a1", msg("m1"));
40
+ const l1 = await s.claimLease(S, "a1", "w1", 1_000, 10_000);
41
+ assert.equal(l1.maxSeq, 1);
42
+ const l2 = await s.claimLease(S, "a1", "w2", 1_000, 20_000);
43
+ assert.deepEqual(l2.messages.map((m) => m.seq), [1]);
44
+ await s.releaseLease(S, "a1", "w2");
45
+ const l3 = await s.claimLease(S, "a1", "w3", 1_000, 20_500);
46
+ assert.deepEqual(l3.messages.map((m) => m.seq), [1]);
47
+ await s.ack(S, "a1", "w3", 1);
48
+ assert.equal(await s.append(S, "a1", msg("m2")), 2);
49
+ });
50
+ run("lease 窗内 append:后到消息在下次 claim 时可见(lease 只覆盖 claim 时刻快照)", async (make) => {
51
+ const s = make();
52
+ await s.append(S, "a1", msg("m1"));
53
+ const l = await s.claimLease(S, "a1", "w1", 60_000, 10_000);
54
+ assert.equal(l.maxSeq, 1);
55
+ await s.append(S, "a1", msg("m2"));
56
+ await s.ack(S, "a1", "w1", l.maxSeq);
57
+ assert.equal(await s.peekCount(S, "a1"), 1);
58
+ const next = await s.claimLease(S, "a1", "w2", 60_000, 20_000);
59
+ assert.deepEqual(next.messages.map((m) => m.content), ["m2"]);
60
+ });
61
+ run("drop 清整箱;reap 按最新消息龄整箱老化", async (make) => {
62
+ const s = make();
63
+ await s.append(S, "a1", msg("old", 1_000));
64
+ await s.append(S, "a2", msg("fresh", 90_000));
65
+ assert.equal(await s.reap(S, 100_000, { maxAgeMs: 50_000 }), 1);
66
+ assert.equal(await s.peekCount(S, "a1"), 0);
67
+ assert.equal(await s.peekCount(S, "a2"), 1);
68
+ await s.drop(S, "a2");
69
+ assert.equal(await s.peekCount(S, "a2"), 0);
70
+ });
71
+ run("RB-86 空箱 ⇒ null:没消息就不发租约(既不假装投递,也不 fence 掉后来真正的消费者)", async (make) => {
72
+ const s = make();
73
+ assert.equal(await s.claimLease(S, "never-written", "o1", 60_000, 1_000), null, "从未写过的箱");
74
+ await s.append(S, "a1", msg("m1"));
75
+ const first = await s.claimLease(S, "a1", "o1", 60_000, 1_000);
76
+ await s.ack(S, "a1", "o1", first.maxSeq);
77
+ assert.equal(await s.peekCount(S, "a1"), 0);
78
+ assert.equal(await s.claimLease(S, "a1", "o1", 60_000, 2_000), null, "全部 ack 后箱内零可见消息");
79
+ assert.equal(await s.claimLease(S, "a2", "ghost", 60_000, 3_000), null);
80
+ await s.append(S, "a2", msg("real", 4_000));
81
+ const real = await s.claimLease(S, "a2", "o2", 60_000, 4_000);
82
+ assert.deepEqual(real?.messages.map((m) => m.content), ["real"], "空取不该留下 fence 后来者的幽灵租约");
83
+ });
84
+ run("RB-261 守恒律:一条消息在被 ack 消费之前必须至少投递过一次(maxSeq 不得覆盖未投递项)", async (make) => {
85
+ const s = make();
86
+ const appended = ["m1", "m2", "m3"];
87
+ for (const [i, c] of appended.entries())
88
+ await s.append(S, "a1", msg(c, 1_000 + i));
89
+ const delivered = new Set();
90
+ let round = 0;
91
+ while ((await s.peekCount(S, "a1")) > 0) {
92
+ round += 1;
93
+ assert.ok(round < 10, "要么有限轮内收敛,要么就是投递不动了");
94
+ const owner = `w${round}`;
95
+ const lease = await s.claimLease(S, "a1", owner, 60_000, 10_000 + round);
96
+ assert.notEqual(lease, null, "还有可见消息时 claimLease 不得返回 null");
97
+ for (const m of lease.messages)
98
+ delivered.add(m.content);
99
+ await s.ack(S, "a1", owner, lease.maxSeq);
100
+ }
101
+ assert.deepEqual([...delivered].sort(), [...appended].sort(), "每条被消费掉的消息都必须先被投递过");
102
+ });
103
+ run("releaseLease owner-fenced:非持有者的 release 是 no-op(活租约的单消费者窗口不被外人撬开)", async (make) => {
104
+ const s = make();
105
+ await s.append(S, "a1", msg("m1"));
106
+ const held = await s.claimLease(S, "a1", "owner-A", 60_000, 10_000);
107
+ assert.equal(held.messages.length, 1);
108
+ await s.releaseLease(S, "a1", "not-the-owner");
109
+ assert.equal(await s.claimLease(S, "a1", "rival", 60_000, 11_000), null, "A 的活租约必须仍然 fence 住 rival");
110
+ await s.releaseLease(S, "a1", "owner-A");
111
+ const after = await s.claimLease(S, "a1", "rival", 60_000, 12_000);
112
+ assert.deepEqual(after?.messages.map((m) => m.seq), [1], "归还后同一条按原 seq 回可见");
113
+ });
114
+ await settle();
115
+ }
116
+ export async function mailboxAckOwnershipContract(mk, runAssertion) {
117
+ const { run: runRaw, settle } = beginContract(runAssertion);
118
+ const run = (name, fn) => runRaw(name, () => withStores(mk, fn));
119
+ const S = MAILBOX_CONTRACT_SCOPE;
120
+ run("a non-holder's ack does not delete messages held under another owner's LIVE lease", async (make) => {
121
+ const s = make();
122
+ await s.append(S, "a1", { content: "m1", sentAt: 1 });
123
+ await s.append(S, "a1", { content: "m2", sentAt: 2 });
124
+ const lease = await s.claimLease(S, "a1", "consumer-A", 60_000, 10_000);
125
+ assert.equal(lease?.messages.length, 2);
126
+ await s.ack(S, "a1", "stale-actor", lease.maxSeq);
127
+ assert.equal(await s.peekCount(S, "a1"), 2, "the messages must survive — this caller never held the lease");
128
+ });
129
+ run("angle 4 — internal consistency: releaseLease and ack now apply the SAME owner fence (was the asymmetry that proved this was an oversight)", async (make) => {
130
+ const s = make();
131
+ await s.append(S, "a1", { content: "m1", sentAt: 1 });
132
+ await s.claimLease(S, "a1", "real-owner", 60_000, 10_000);
133
+ await s.releaseLease(S, "a1", "wrong-owner");
134
+ await s.ack(S, "a1", "wrong-owner", 1);
135
+ assert.equal(await s.peekCount(S, "a1"), 1, "neither operation should have touched anything");
136
+ });
137
+ run("angle 5 — ack with no lease EVER claimed is a no-op, not a silent wipe", async (make) => {
138
+ const s = make();
139
+ await s.append(S, "a1", { content: "m1", sentAt: 1 });
140
+ await s.ack(S, "a1", "nobody", Number.MAX_SAFE_INTEGER);
141
+ assert.equal(await s.peekCount(S, "a1"), 1);
142
+ });
143
+ run("the legitimate holder's own ack still works (regression check — the fix must not break the happy path)", async (make) => {
144
+ const s = make();
145
+ await s.append(S, "a1", { content: "m1", sentAt: 1 });
146
+ await s.append(S, "a1", { content: "m2", sentAt: 2 });
147
+ const lease = await s.claimLease(S, "a1", "consumer-A", 60_000, 10_000);
148
+ await s.ack(S, "a1", "consumer-A", lease.maxSeq);
149
+ assert.equal(await s.peekCount(S, "a1"), 0);
150
+ });
151
+ run("angle 3 in detail — the real loss sequence (stale owner outlives its own expired lease, a NEW owner has since taken over): the new owner's custody survives", async (make) => {
152
+ const s = make();
153
+ await s.append(S, "a1", { content: "ship-the-release", sentAt: 1 });
154
+ await s.append(S, "a1", { content: "notify-oncall", sentAt: 2 });
155
+ const leaseA = await s.claimLease(S, "a1", "consumer-A", 1_000, 1_000);
156
+ assert.equal(leaseA?.messages.length, 2);
157
+ const leaseB = await s.claimLease(S, "a1", "consumer-B", 60_000, 5_000);
158
+ assert.equal(leaseB?.messages.length, 2);
159
+ await s.ack(S, "a1", "consumer-A", leaseA.maxSeq);
160
+ assert.equal(await s.peekCount(S, "a1"), 2, "B's live custody must survive A's stale ack");
161
+ await s.ack(S, "a1", "consumer-B", leaseB.maxSeq);
162
+ assert.equal(await s.peekCount(S, "a1"), 0);
163
+ });
164
+ await settle();
165
+ }
166
+ export async function mailboxBundledOnlyContract(mk, runAssertion) {
167
+ const { run: runRaw, settle } = beginContract(runAssertion);
168
+ const run = (name, fn) => runRaw(name, () => withStores(mk, fn));
169
+ run("RB-251(黑板 [1937]):handle 含 NUL 被两个后端一致拒绝,且不得跨 (scope,handle) 串箱", async (make) => {
170
+ const s = make();
171
+ const NUL = String.fromCharCode(0);
172
+ await assert.rejects(s.append("s", `a${NUL}b`, msg("secret")));
173
+ assert.equal(await s.peekCount(`s${NUL}a`, "b"), 0, "歧义键下不得出现串箱可读");
174
+ await s.append("s", "plain", msg("ok"));
175
+ assert.equal(await s.peekCount("s", "plain"), 1);
176
+ });
177
+ run("多租户:同名 handle 在不同 scope 下是两个互不可见的箱(各自独立铸 seq)", async (make) => {
178
+ const s = make();
179
+ assert.equal(await s.append("tenant-a", "shared-handle", msg("for-a")), 1);
180
+ assert.equal(await s.append("tenant-b", "shared-handle", msg("for-b")), 1, "另一个 scope 的箱独立铸号");
181
+ assert.equal(await s.peekCount("tenant-a", "shared-handle"), 1);
182
+ const a = await s.claimLease("tenant-a", "shared-handle", "w", 60_000, 10_000);
183
+ assert.deepEqual(a.messages.map((m) => m.content), ["for-a"]);
184
+ const b = await s.claimLease("tenant-b", "shared-handle", "w", 60_000, 10_000);
185
+ assert.deepEqual(b.messages.map((m) => m.content), ["for-b"]);
186
+ await s.ack("tenant-a", "shared-handle", "w", a.maxSeq);
187
+ assert.equal(await s.peekCount("tenant-a", "shared-handle"), 0);
188
+ assert.equal(await s.peekCount("tenant-b", "shared-handle"), 1, "跨 scope 的 ack 不得越界");
189
+ await s.drop("tenant-b", "shared-handle");
190
+ assert.equal(await s.peekCount("tenant-b", "shared-handle"), 0);
191
+ });
192
+ await settle();
193
+ }
@@ -0,0 +1,3 @@
1
+ import type { SessionRepo } from "../../internal/harness-types.js";
2
+ import { type ContractAssertionRunner } from "./contract-harness.js";
3
+ export declare function sessionRepoContract(make: () => SessionRepo, runAssertion?: ContractAssertionRunner): Promise<void>;
@@ -0,0 +1,36 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { beginContract } from "./contract-harness.js";
3
+ const SESSION_ENTRY = (id, parentId) => ({
4
+ id,
5
+ parentId,
6
+ type: "message",
7
+ timestamp: "2026-07-30T00:00:00.000Z",
8
+ message: { role: "user", content: [{ type: "text", text: `t-${id}` }], timestamp: 1785340800000 },
9
+ });
10
+ export async function sessionRepoContract(make, runAssertion) {
11
+ const { run, settle } = beginContract(runAssertion);
12
+ const A = "0198fa00-0000-7000-8000-00000000000a";
13
+ run("RB-360 create({id}) on an id that ALREADY has history is an idempotent REOPEN — never a wipe", async () => {
14
+ const repo = make();
15
+ const sess = await repo.create({ id: A });
16
+ await sess.getStorage().appendEntry(SESSION_ENTRY("e1", null));
17
+ await sess.getStorage().appendEntry(SESSION_ENTRY("e2", "e1"));
18
+ const again = await repo.create({ id: A });
19
+ const entries = await again.getStorage().getEntries();
20
+ assert.deepEqual(entries.map((e) => e.id), ["e1", "e2"]);
21
+ const exported = (await repo.exportEntries?.(A)) ?? [];
22
+ assert.deepEqual(exported.map((e) => e.id), ["e1", "e2"]);
23
+ });
24
+ run("create({id}) fresh → open() sees appended entries (round-trip floor)", async () => {
25
+ const repo = make();
26
+ const sess = await repo.create({ id: A });
27
+ await sess.getStorage().appendEntry(SESSION_ENTRY("e1", null));
28
+ const opened = await repo.open({ id: A, createdAt: "" });
29
+ assert.deepEqual((await opened.getStorage().getEntries()).map((e) => e.id), ["e1"]);
30
+ });
31
+ run("open(missing) throws not_found — never a silent create", async () => {
32
+ const repo = make();
33
+ await assert.rejects(repo.open({ id: "0198fa00-0000-7000-8000-0000000000ff", createdAt: "" }), /not.?found/i);
34
+ });
35
+ await settle();
36
+ }
@@ -0,0 +1,3 @@
1
+ import type { ToolResultStore } from "../tool-result-store.js";
2
+ import { type ContractAssertionRunner } from "./contract-harness.js";
3
+ export declare function toolResultStoreContract(make: () => ToolResultStore, runAssertion?: ContractAssertionRunner): Promise<void>;
@@ -0,0 +1,35 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { beginContract } from "./contract-harness.js";
3
+ export async function toolResultStoreContract(make, runAssertion) {
4
+ const { run, settle } = beginContract(runAssertion);
5
+ run("put/get full + sliced; write-once no-op; unknown → undefined", async () => {
6
+ const store = make();
7
+ const ref = "tr_s_c";
8
+ await store.put(ref, "0123456789");
9
+ assert.deepEqual(await store.get(ref), { content: "0123456789", offset: 0, totalChars: 10 });
10
+ assert.deepEqual(await store.get(ref, { offset: 2, limit: 3 }), { content: "234", offset: 2, totalChars: 10 });
11
+ await store.put(ref, "IGNORED");
12
+ assert.equal((await store.get(ref)).content, "0123456789");
13
+ assert.equal(await store.get("tr_unknown"), undefined);
14
+ });
15
+ run("RB-266 unsafe refs are rejected at the WRITE by every backend; the read face degrades instead", async () => {
16
+ const store = make();
17
+ const NUL = String.fromCharCode(0);
18
+ const unsafe = ["", ".", "..", "a/b", "../escape", "dir\\ref", `tr_x${NUL}y`];
19
+ for (const ref of unsafe) {
20
+ await assert.rejects((async () => store.put(ref, "payload"))(), `put(${JSON.stringify(ref)}) must reject — a durable backend cannot key it`);
21
+ assert.equal(await store.get(ref), undefined, "get of an unsafe ref degrades to undefined");
22
+ }
23
+ await store.put("tr_sess-1_call.7", "ok");
24
+ assert.equal((await store.get("tr_sess-1_call.7")).content, "ok");
25
+ });
26
+ run("RB-273 a ref the contract ACCEPTS round-trips on every backend (no backend is stricter than the rule)", async () => {
27
+ const store = make();
28
+ for (const ref of ["tr_sess_call:1", "tr_sess_call%2F1", "tr_sess_call 1", "tr_sess_日本語"]) {
29
+ await store.put(ref, `payload-${ref}`);
30
+ assert.equal((await store.get(ref))?.content, `payload-${ref}`, `put(${JSON.stringify(ref)}) must round-trip here`);
31
+ }
32
+ assert.equal((await store.get("tr_sess_call:1")).content, "payload-tr_sess_call:1");
33
+ });
34
+ await settle();
35
+ }
@@ -10,6 +10,8 @@ export interface TaskNotificationPayload {
10
10
  source?: string;
11
11
  stoppedBy?: "user" | "parent" | "system" | (string & {});
12
12
  summary: string;
13
+ error?: string;
14
+ errorCode?: string;
13
15
  exitCode?: number;
14
16
  result?: string;
15
17
  partial?: boolean;
@@ -66,6 +66,8 @@ export declare function settleBackgroundAgentLane(core: DurableAgentCore, id: st
66
66
  result?: string;
67
67
  resultFull?: string;
68
68
  error?: string;
69
+ errorCode?: string;
70
+ retryable?: boolean;
69
71
  stoppedBy?: StopSource;
70
72
  seq?: number;
71
73
  }): "completed" | "failed" | "killed" | undefined;
@@ -98,6 +100,8 @@ export declare function settleRevivedAgentLane(core: DurableAgentCore, id: strin
98
100
  result?: string;
99
101
  resultFull?: string;
100
102
  error?: string;
103
+ errorCode?: string;
104
+ retryable?: boolean;
101
105
  }): "completed" | "failed" | "killed" | undefined;
102
106
  export declare function unmarkRetainedContinuationLane(core: DurableAgentCore, id: string): void;
103
107
  export declare function attachAgentNotifyLane(core: DurableAgentCore, id: string, notify: NonNullable<BackgroundAgentTaskHandle["notify"]>, cycle?: number): void;
@@ -3,6 +3,7 @@ import { uuidv7 } from "../internal/harness.js";
3
3
  import { canAccessAgentRecord, BackgroundAgentStoreError, } from "./background-agent-store.js";
4
4
  import { shutdownDebug } from "./shutdown-debug.js";
5
5
  import { delimitUntrusted } from "./untrusted-text.js";
6
+ import { boundedRedactedSummary } from "./untrusted-egress.js";
6
7
  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";
7
8
  import { buildToolResultRef, OFFLOAD_TOOL_NAME } from "./tool-result-store.js";
8
9
  export function ensureDurableHeartbeatLane(core) {
@@ -636,6 +637,7 @@ export async function consumeParkedFlipLane(core, id, stores, ticket) {
636
637
  handle.resolveParkedStop = undefined;
637
638
  handle.updatedAt = Date.now();
638
639
  handle.completionId = undefined;
640
+ handle.terminalNotified = undefined;
639
641
  if (flipped.seq !== undefined)
640
642
  handle.cycleSeq = flipped.seq;
641
643
  return true;
@@ -681,6 +683,7 @@ export function settleBackgroundAgentLane(core, id, outcome) {
681
683
  handle.status = outcome.status;
682
684
  mintCompletionId(handle);
683
685
  handle.notify = undefined;
686
+ handle.onReapTerminal = undefined;
684
687
  for (const [, , qResolve] of handle.preAttachQueue ?? [])
685
688
  qResolve?.({ ok: false, reason: "not_running" });
686
689
  handle.preAttachQueue = undefined;
@@ -699,6 +702,10 @@ export function settleBackgroundAgentLane(core, id, outcome) {
699
702
  }
700
703
  if (outcome.error !== undefined && !(outcome.status === "killed" && outcome.error === BG_AGENT_REAP_STOP_ERROR)) {
701
704
  handle.error = outcome.error;
705
+ if (outcome.errorCode !== undefined)
706
+ handle.errorCode = outcome.errorCode;
707
+ if (outcome.retryable !== undefined)
708
+ handle.errorRetryable = outcome.retryable;
702
709
  }
703
710
  handle.updatedAt = Date.now();
704
711
  if (outcome.seq !== undefined)
@@ -840,6 +847,7 @@ export function reviveBackgroundAgentLane(core, id, access, abort) {
840
847
  handle.resultIsPartial = undefined;
841
848
  handle.stopSource = undefined;
842
849
  handle.completionId = undefined;
850
+ handle.terminalNotified = undefined;
843
851
  handle.reviveCycle = (handle.reviveCycle ?? 0) + 1;
844
852
  handle.cycleSeq = (handle.cycleSeq ?? 1) + 1;
845
853
  handle.updatedAt = Date.now();
@@ -1076,6 +1084,11 @@ ${resultText}` : "(no result text)"}`;
1076
1084
  retrieval_status: retrieval,
1077
1085
  ...(handle.cycleSeq !== undefined ? { seq: handle.cycleSeq } : {}),
1078
1086
  ...(handle.status === "killed" && handle.stoppedBy !== undefined ? { stoppedBy: handle.stoppedBy } : {}),
1087
+ ...(handle.status === "failed" && handle.error !== undefined
1088
+ ? { error: delimitUntrusted("agent error", boundedRedactedSummary(handle.error, 300)) }
1089
+ : {}),
1090
+ ...(handle.status === "failed" && handle.errorCode !== undefined ? { errorCode: handle.errorCode } : {}),
1091
+ ...(handle.status === "failed" && handle.errorRetryable !== undefined ? { retryable: handle.errorRetryable } : {}),
1079
1092
  ...(handle.resultIsPartial ? { partial_result: true } : {}),
1080
1093
  ...(handle.completionId !== undefined ? { completionId: handle.completionId } : {}),
1081
1094
  },
@@ -1,5 +1,5 @@
1
1
  import { delimitUntrusted } from "./untrusted-text.js";
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_BYTES, TASK_OUTPUT_MAX_CHARS, mintCompletionId, clipMonitorEvent, clipMonitorLine, terminalTaskSummary, accountDroppedBytes, rollSpoolText, statusFromBackground, droppedGapNote, firstDropNote, alreadyTerminalStopNote, clipTaskOutput, sleepPollStep, } from "./task-registry-shared.js";
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, rollSpoolText, statusFromBackground, droppedGapNote, firstDropNote, alreadyTerminalStopNote, clipTaskOutput, sleepPollStep, } from "./task-registry-shared.js";
3
3
  import { buildToolResultRef, OFFLOAD_TOOL_NAME } from "./tool-result-store.js";
4
4
  export function registerMonitorLane(core, input) {
5
5
  assertOwnership(input, "registerMonitor");
@@ -49,14 +49,14 @@ function spillRolledMonitorChunk(handle, stream, dropped) {
49
49
  const store = handle.toolResultStore;
50
50
  if (store === undefined)
51
51
  return;
52
- const used = handle.spillBytesUsed ?? 0;
53
- if (used >= MONITOR_SPILL_CAP_BYTES) {
52
+ const used = handle.spillCharsUsed ?? 0;
53
+ if (used >= MONITOR_SPILL_CAP_CHARS) {
54
54
  handle.spillCapped = true;
55
55
  return;
56
56
  }
57
57
  const n = stream === "out" ? (handle.spillSegCount ?? 0) : (handle.spillErrSegCount ?? 0);
58
58
  const ref = buildToolResultRef(handle.spillSessionId ?? "no-session", `${handle.id}_${stream}_seg${n}`);
59
- handle.spillBytesUsed = used + dropped.length;
59
+ handle.spillCharsUsed = used + dropped.length;
60
60
  if (stream === "out")
61
61
  handle.spillSegCount = n + 1;
62
62
  else
@@ -88,7 +88,7 @@ function monitorSpillNote(handle) {
88
88
  const coverage = handle.spillFailed === true
89
89
  ? " — a write failed partway through; the ref chain may be INCOMPLETE, read what is there"
90
90
  : handle.spillCapped === true
91
- ? ` — spill cap (${MONITOR_SPILL_CAP_BYTES} bytes) reached; earlier segments retained, later rolls were not spilled`
91
+ ? ` — spill cap (${MONITOR_SPILL_CAP_CHARS} chars) reached; earlier segments retained, later rolls were not spilled`
92
92
  : "";
93
93
  return `; spilled to ${clauses.join(", ")} (read back via ${OFFLOAD_TOOL_NAME})${coverage}`;
94
94
  }
@@ -359,7 +359,7 @@ function explicitStopTerminalNote(handle, alreadyGone) {
359
359
  task_type: "monitor",
360
360
  ...(handle.toolUseId !== undefined ? { toolUseId: handle.toolUseId } : {}),
361
361
  status: "killed",
362
- stoppedBy: handle.stoppedBy,
362
+ ...(handle.stoppedBy !== undefined ? { stoppedBy: handle.stoppedBy } : {}),
363
363
  summary: `${terminalTaskSummary("monitor", label, "killed")} — ${detail}${droppedGapNote(handle.spool)}`,
364
364
  ...(handle.completionId !== undefined ? { completionId: handle.completionId } : {}),
365
365
  });
@@ -16,6 +16,8 @@ export interface UnifiedTaskOutput {
16
16
  retrieval_status: TaskRetrievalStatus;
17
17
  content?: string;
18
18
  error?: string;
19
+ errorCode?: string;
20
+ retryable?: boolean;
19
21
  stoppedBy?: StopSource;
20
22
  seq?: number;
21
23
  partial_result?: boolean;
@@ -135,9 +137,13 @@ export interface BackgroundAgentTaskHandle extends SemaTaskHandle {
135
137
  resultFull?: string;
136
138
  spillRef?: string;
137
139
  error?: string;
140
+ errorCode?: string;
141
+ errorRetryable?: boolean;
138
142
  resultIsPartial?: boolean;
139
143
  stopSource?: StopSource;
140
144
  stoppedBy?: StopSource;
145
+ onReapTerminal?: () => void;
146
+ terminalNotified?: true;
141
147
  }
142
148
  export interface MonitorTimers {
143
149
  setInterval: (fn: () => unknown, ms: number) => unknown;
@@ -188,7 +194,7 @@ export interface MonitorTaskHandle extends SemaTaskHandle {
188
194
  spillSessionId?: string;
189
195
  spillSegCount?: number;
190
196
  spillErrSegCount?: number;
191
- spillBytesUsed?: number;
197
+ spillCharsUsed?: number;
192
198
  spillCapped?: true;
193
199
  spillFailed?: true;
194
200
  }
@@ -237,7 +243,7 @@ export declare function statusFromBackground(status: string, exitCode?: number):
237
243
  export declare function rollSpoolText(spool: {
238
244
  rolledChars: number;
239
245
  }, s: string, cap: number, onDrop?: (dropped: string) => void): string;
240
- export declare const MONITOR_SPILL_CAP_BYTES: number;
246
+ export declare const MONITOR_SPILL_CAP_CHARS: number;
241
247
  export declare function accountDroppedBytes(spool: {
242
248
  droppedBytes?: number;
243
249
  dropUnknown?: true;
@@ -94,7 +94,7 @@ export function rollSpoolText(spool, s, cap, onDrop) {
94
94
  onDrop?.(dropped);
95
95
  return s.slice(0, half) + s.slice(s.length - half);
96
96
  }
97
- export const MONITOR_SPILL_CAP_BYTES = 64 * 1024 * 1024;
97
+ export const MONITOR_SPILL_CAP_CHARS = 64 * 1024 * 1024;
98
98
  export function accountDroppedBytes(spool, poll) {
99
99
  const d = poll.bytesDroppedBeforeCursor ?? 0;
100
100
  if (d > 0)
@@ -133,6 +133,8 @@ export declare class TaskRegistry {
133
133
  result?: string;
134
134
  resultFull?: string;
135
135
  error?: string;
136
+ errorCode?: string;
137
+ retryable?: boolean;
136
138
  stoppedBy?: StopSource;
137
139
  seq?: number;
138
140
  }): "completed" | "failed" | "killed" | undefined;
@@ -166,9 +168,13 @@ export declare class TaskRegistry {
166
168
  result?: string;
167
169
  resultFull?: string;
168
170
  error?: string;
171
+ errorCode?: string;
172
+ retryable?: boolean;
169
173
  }): "completed" | "failed" | "killed" | undefined;
170
174
  unmarkRetainedContinuation(id: string): void;
171
175
  attachAgentNotify(id: string, notify: NonNullable<BackgroundAgentTaskHandle["notify"]>, cycle?: number): void;
176
+ attachAgentTerminalNotifier(id: string, fn: () => void): void;
177
+ claimAgentTerminalNotify(id: string): boolean;
172
178
  deliverToRunningAgent(id: string, access: TaskAccess, notification: import("./task-notification.js").TaskNotificationPayload, opts?: {
173
179
  priority?: import("./task-notification.js").SystemInjectionPriority;
174
180
  }): Promise<{
@@ -52,6 +52,23 @@ function bashMirrorGapNote(handle) {
52
52
  ? " [!] The output file is INCOMPLETE (one or more writes to it failed) — trust the result text / TaskOutput over the file."
53
53
  : "";
54
54
  }
55
+ function explicitStopBashTerminalNote(handle, alreadyGone) {
56
+ const resultText = handle.spool !== undefined ? bashTerminalResult(handle.spool) : "";
57
+ const detail = alreadyGone
58
+ ? "killed before completion (stopped via TaskStop; the process was already gone)"
59
+ : "killed before completion (stopped via TaskStop)";
60
+ handle.onTerminal?.({
61
+ task_id: handle.id,
62
+ task_type: "background_bash",
63
+ ...(handle.toolUseId !== undefined ? { toolUseId: handle.toolUseId } : {}),
64
+ ...(handle.outputFile !== undefined ? { output_file: handle.outputFile } : {}),
65
+ status: "killed",
66
+ ...(handle.stoppedBy !== undefined ? { stoppedBy: handle.stoppedBy } : {}),
67
+ summary: `${terminalTaskSummary("bash", (handle.description ?? "background command").slice(0, 200), "killed")} — ${detail}${bashMirrorGapNote(handle)}${handle.spool !== undefined ? droppedGapNote(handle.spool) : ""}`,
68
+ ...(resultText.length > 0 ? { result: resultText, partial: true } : {}),
69
+ ...(handle.completionId !== undefined ? { completionId: handle.completionId } : {}),
70
+ });
71
+ }
55
72
  function monitorInTimeoutWindow(handle) {
56
73
  return handle.status === "running" && handle.deadlineAt !== undefined && handle.timers.now() < handle.deadlineAt;
57
74
  }
@@ -145,6 +162,23 @@ export class TaskRegistry {
145
162
  attachAgentNotify(id, notify, cycle) {
146
163
  return attachAgentNotifyLane(this.core, id, notify, cycle);
147
164
  }
165
+ attachAgentTerminalNotifier(id, fn) {
166
+ const handle = this.handles.get(id);
167
+ if (!handle || handle.type !== "background_agent")
168
+ return;
169
+ if (handle.status !== "running" && handle.status !== "parked" && handle.status !== "pending")
170
+ return;
171
+ handle.onReapTerminal = fn;
172
+ }
173
+ claimAgentTerminalNotify(id) {
174
+ const handle = this.handles.get(id);
175
+ if (!handle || handle.type !== "background_agent")
176
+ return true;
177
+ if (handle.terminalNotified === true)
178
+ return false;
179
+ handle.terminalNotified = true;
180
+ return true;
181
+ }
148
182
  async deliverToRunningAgent(id, access, notification, opts) {
149
183
  return deliverToRunningAgentLane(this.core, id, access, notification, opts);
150
184
  }
@@ -471,8 +505,17 @@ export class TaskRegistry {
471
505
  if (!canAccess(handle, access))
472
506
  continue;
473
507
  this.markStopSource(handle.id, "system");
508
+ const reapTerminalNote = handle.onReapTerminal;
474
509
  handle.abort.abort();
475
510
  this.settleBackgroundAgent(handle.id, { status: "killed", error: "session released" });
511
+ if (reapTerminalNote !== undefined && handle.terminalNotified !== true) {
512
+ handle.terminalNotified = true;
513
+ try {
514
+ reapTerminalNote();
515
+ }
516
+ catch {
517
+ }
518
+ }
476
519
  reaped++;
477
520
  }
478
521
  this.pokeBgQuiescence(sessionId);
@@ -1122,7 +1165,7 @@ The durable record is still marked running (last update ${new Date(row.updatedAt
1122
1165
  handle.status = "killed";
1123
1166
  handle.updatedAt = Date.now();
1124
1167
  mintCompletionId(handle);
1125
- this.pokeBgQuiescence(handle.owner);
1168
+ this.notifyTerminalOnce(handle, () => explicitStopBashTerminalNote(handle, true));
1126
1169
  return {
1127
1170
  content: `Terminated ${handle.id} (the process was already gone).`,
1128
1171
  details: {
@@ -1151,7 +1194,7 @@ The durable record is still marked running (last update ${new Date(row.updatedAt
1151
1194
  handle.status = "killed";
1152
1195
  handle.updatedAt = Date.now();
1153
1196
  mintCompletionId(handle);
1154
- this.pokeBgQuiescence(handle.owner);
1197
+ this.notifyTerminalOnce(handle, () => explicitStopBashTerminalNote(handle, false));
1155
1198
  return {
1156
1199
  content: `Terminated ${handle.id}.`,
1157
1200
  details: {
@@ -1193,9 +1236,10 @@ export function createTaskOutputTool(opts) {
1193
1236
  let effectiveTimeoutMs = requestedMs;
1194
1237
  let waitClampNote = "";
1195
1238
  const wall = opts.deadlineMs?.();
1239
+ const requestedWaitMs = Math.max(1, Math.floor(requestedMs ?? BLOCK_DEFAULT_TIMEOUT_MS));
1196
1240
  if (args.block !== false && wall !== undefined) {
1197
1241
  const remainMs = wall - Date.now() - BLOCK_WAIT_WRITEOUT_RESERVE_MS;
1198
- const wantMs = Math.min(BLOCK_MAX_TIMEOUT_MS, Math.max(1, Math.floor(requestedMs ?? BLOCK_DEFAULT_TIMEOUT_MS)));
1242
+ const wantMs = Math.min(BLOCK_MAX_TIMEOUT_MS, requestedWaitMs);
1199
1243
  if (remainMs <= 0) {
1200
1244
  effectiveTimeoutMs = 1;
1201
1245
  waitClampNote =
@@ -1203,7 +1247,7 @@ export function createTaskOutputTool(opts) {
1203
1247
  }
1204
1248
  else if (remainMs < wantMs) {
1205
1249
  effectiveTimeoutMs = remainMs;
1206
- waitClampNote = `\n\nNOTE: wait clamped to ${Math.round(remainMs / 1000)}s (requested ${Math.round(wantMs / 1000)}s) — the task's wall-clock deadline is near. If the task is still running after this wait, do NOT re-wait: proceed with other work or write out your results now.`;
1250
+ waitClampNote = `\n\nNOTE: wait clamped to ${Math.round(remainMs / 1000)}s (requested ${Math.round(requestedWaitMs / 1000)}s) — the task's wall-clock deadline is near. If the task is still running after this wait, do NOT re-wait: proceed with other work or write out your results now.`;
1207
1251
  }
1208
1252
  }
1209
1253
  const r = await opts.registry.pollTask(id, { owner: ctx.taskId ?? opts.owner, scope: ctx.principal ?? opts.scope, ...((ctx.sessionId ?? opts.sessionId) !== undefined ? { sessionId: ctx.sessionId ?? opts.sessionId } : {}) }, {
@@ -45,6 +45,7 @@ export declare class ScopedToolResultStore implements ToolResultStore {
45
45
  }
46
46
  export declare function isVolatileOffloadStore(store: ToolResultStore): boolean;
47
47
  export declare const OFFLOAD_TOOL_NAME = "ReadToolResult";
48
+ export declare function offloadPagebackHint(ref: string, form: "preview" | "cleared", reachableTools?: ReadonlySet<string>): string;
48
49
  export declare const PERSISTED_OUTPUT_PREFIX = "<persisted-output ref=";
49
50
  export declare const DEFAULT_TOOL_RESULT_THRESHOLD_CHARS = 20000;
50
51
  export declare function firstPartyOffloadPolicy(toolName: string): {
@@ -54,6 +55,6 @@ export declare function firstPartyOffloadPolicy(toolName: string): {
54
55
  export declare function buildPreview(full: string, ref: string, sizes?: {
55
56
  head: number;
56
57
  tail: number;
57
- }): string;
58
- export declare function withToolResultOffload(tool: AgentTool, store: ToolResultStore, thresholdChars: number, sessionId: string): AgentTool;
58
+ }, reachableTools?: ReadonlySet<string>): string;
59
+ export declare function withToolResultOffload(tool: AgentTool, store: ToolResultStore, thresholdChars: number, sessionId: string, reachableTools?: () => ReadonlySet<string> | undefined): AgentTool;
59
60
  export declare function createReadToolResultTool(store: ToolResultStore): AgentTool;