@sema-agent/core 5.2.0 → 5.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 (43) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/brain/anthropic.js +12 -2
  3. package/dist/brain/media-degrade.d.ts +14 -0
  4. package/dist/brain/media-degrade.js +30 -0
  5. package/dist/brain/openai.js +9 -11
  6. package/dist/brain/timeout.d.ts +14 -0
  7. package/dist/brain/timeout.js +114 -0
  8. package/dist/core/a2a-task-state.d.ts +15 -0
  9. package/dist/core/a2a-task-state.js +68 -0
  10. package/dist/core/a2a.d.ts +42 -0
  11. package/dist/core/a2a.js +651 -0
  12. package/dist/core/checkpoint-store.d.ts +6 -1
  13. package/dist/core/checkpoint-store.js +3 -1
  14. package/dist/core/exec-output-tail.d.ts +0 -1
  15. package/dist/core/exec-output-tail.js +0 -6
  16. package/dist/core/mcp.d.ts +11 -4
  17. package/dist/core/mcp.js +11 -31
  18. package/dist/core/protocol-naming.d.ts +7 -0
  19. package/dist/core/protocol-naming.js +32 -0
  20. package/dist/core/protocol-table.d.ts +3 -1
  21. package/dist/core/protocol-table.js +37 -32
  22. package/dist/core/runner/prepare-task.d.ts +9 -0
  23. package/dist/core/runner/prepare-task.js +52 -15
  24. package/dist/core/runner/runtask.js +34 -4
  25. package/dist/core/runner/tool-disclosure.d.ts +1 -1
  26. package/dist/core/runner/tool-disclosure.js +1 -1
  27. package/dist/core/runner/turn-attachments.d.ts +1 -2
  28. package/dist/core/runner/turn-attachments.js +1 -12
  29. package/dist/core/store-contracts/background-agent-store-contract.d.ts +5 -0
  30. package/dist/core/store-contracts/background-agent-store-contract.js +213 -0
  31. package/dist/core/task-registry-agent.d.ts +14 -1
  32. package/dist/core/task-registry-agent.js +2 -2
  33. package/dist/core/types.d.ts +18 -2
  34. package/dist/engine/execution-env/node-execution-env.d.ts +0 -1
  35. package/dist/engine/execution-env/node-execution-env.js +12 -30
  36. package/dist/index.d.ts +8 -3
  37. package/dist/index.js +6 -2
  38. package/dist/prompt-assembly/event-registry.js +1 -0
  39. package/dist/tools/fs/fs-read.js +5 -3
  40. package/dist/tools/fs/fs-shared.d.ts +1 -0
  41. package/dist/tools/fs/fs-shared.js +1 -0
  42. package/dist/tools/web.js +12 -4
  43. package/package.json +1 -1
@@ -40,6 +40,7 @@ import { refuseOutOfContractDecision, toolPolicyNameSets } from "../tool-policy.
40
40
  import { defaultTaskRegistry } from "../task-registry.js";
41
41
  import { discloseDroppedPending, PendingSessionNotifications, renderTaskNotificationXml, SystemInjectionQueue, taskNotificationDedupKey } from "../task-notification.js";
42
42
  import { ToolDetachHub } from "../tool-detach.js";
43
+ import { workflowSizeGuidelineChangeNotice } from "../../orchestration/workflow-size-guideline.js";
43
44
  import { DEFAULT_MAX_TURNS } from "../../config/defaults.js";
44
45
  import { structuredFrom, toolOutputFrom } from "./tool-output-projection.js";
45
46
  export { DEFAULT_MAX_TURNS };
@@ -51,7 +52,7 @@ function createRunState() {
51
52
  budget: { remainingMicroUsd: undefined, maxCostMicroUsd: undefined, overBudget: () => false, streamCancel: false, callOutputChars: 0, lastStreamBudgetCheck: 0, projectedOverBudget: () => false },
52
53
  turn: { callStartAt: undefined, firstTokenAt: undefined, turnUsage: undefined, turnUsageMissing: false, turnStopReason: undefined, lastTurnHadToolCalls: false, toolBatch: [] },
53
54
  counters: { nudgesSent: 0, nudgeIdx: 0, finalizeInjected: false, walltimeSyncBackstopFired: false, compactionFloor: 0, trimForceBackoff: false, callCutoffs: 0, repetitionCuts: 0, repetitionSpared: 0, repetitionEvents: [], REPETITION_EVENTS_CAP: 0, preemptIgnoredReported: false, wroteThisRun: false, finalVerifyInjections: 0, groundingSignalPreR9: false, groundingSignalPostR9: false, cadenceTurns: 0 },
54
- attach: { attachmentsCfg: undefined, agentListingOn: false, skillsListingOn: false, attachState: undefined, dateState: undefined, instrProbe: undefined, instrState: undefined, attachmentsInjected: 0 },
55
+ attach: { attachmentsCfg: undefined, agentListingOn: false, skillsListingOn: false, attachState: undefined, dateState: undefined, instrProbe: undefined, instrState: undefined, sizeGuidelineState: undefined, attachmentsInjected: 0 },
55
56
  };
56
57
  }
57
58
  const MAX_CONSECUTIVE_COMPACTION_FAILURES = 3;
@@ -314,7 +315,10 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
314
315
  }
315
316
  let boundaryAttachmentBytes = 0;
316
317
  let attachmentsPayload;
317
- if ((rs.attach.dateState !== undefined || rs.attach.instrState !== undefined || rs.attach.attachState !== undefined) &&
318
+ if ((rs.attach.dateState !== undefined ||
319
+ rs.attach.instrState !== undefined ||
320
+ rs.attach.sizeGuidelineState !== undefined ||
321
+ rs.attach.attachState !== undefined) &&
318
322
  !boundarySteered &&
319
323
  !rs.counters.finalizeInjected &&
320
324
  rs.counters.finalVerifyInjections === 0 &&
@@ -457,6 +461,16 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
457
461
  rs.attach.dateState.announcedDate = today;
458
462
  }
459
463
  }
464
+ if (rs.attach.sizeGuidelineState !== undefined && rs.turn.lastTurnHadToolCalls) {
465
+ const guideline = rs.attach.sizeGuidelineState.current();
466
+ if (guideline !== rs.attach.sizeGuidelineState.announcedGuideline) {
467
+ due.splice(due.length > 0 && due[0].source === "date_change" ? 1 : 0, 0, {
468
+ source: "workflow_size_guideline_change",
469
+ body: workflowSizeGuidelineChangeNotice(guideline),
470
+ });
471
+ rs.attach.sizeGuidelineState.announcedGuideline = guideline;
472
+ }
473
+ }
460
474
  if (rs.attach.instrState !== undefined && rs.attach.instrProbe !== undefined && rs.turn.lastTurnHadToolCalls) {
461
475
  let probed = null;
462
476
  try {
@@ -1770,6 +1784,10 @@ export class Runner {
1770
1784
  rs.attach.instrProbe !== undefined && prepared.instructionSources !== undefined && prepared.instructionSources.length > 0
1771
1785
  ? { lastAnnouncedHash: new Map(prepared.instructionSources.map((s) => [s.path, s.contentHash])) }
1772
1786
  : undefined;
1787
+ rs.attach.sizeGuidelineState =
1788
+ prepared.workflowSizeGuideline !== undefined
1789
+ ? { announcedGuideline: prepared.workflowSizeGuideline.legGuideline, current: prepared.workflowSizeGuideline.current }
1790
+ : undefined;
1773
1791
  rs.counters.cadenceTurns = 0;
1774
1792
  rs.turn.lastTurnHadToolCalls = false;
1775
1793
  if (rs.attach.attachState !== undefined && rs.attach.attachmentsCfg?.backgroundTasks === true) {
@@ -2620,6 +2638,7 @@ export class Runner {
2620
2638
  catch (err) {
2621
2639
  if (errorCodeOf(err) === "resume.tool_unavailable") {
2622
2640
  await settleTeardownLeg(() => prepared.mcp.dispose(), "mcp.dispose (tool_unavailable leg)", (e) => this.deps.onError?.(e, { phase: "config", sessionId: prepared.sessionId }));
2641
+ await settleTeardownLeg(() => prepared.a2a?.dispose(), "a2a.dispose (tool_unavailable leg)", (e) => this.deps.onError?.(e, { phase: "config", sessionId: prepared.sessionId }));
2623
2642
  await settleTeardownLeg(() => (prepared.ownedEnv && hasDestroy(prepared.ownedEnv) ? prepared.ownedEnv.destroy() : undefined), "ownedEnv.destroy (tool_unavailable leg)", (e) => this.deps.onError?.(e, { phase: "config", sessionId: prepared.sessionId }));
2624
2643
  throw err;
2625
2644
  }
@@ -2640,6 +2659,9 @@ export class Runner {
2640
2659
  unsubBoundary();
2641
2660
  unsub();
2642
2661
  }
2662
+ if (threw === undefined && prepared.brainCallGuardrailRef.timedOut !== undefined) {
2663
+ threw = prepared.brainCallGuardrailRef.timedOut;
2664
+ }
2643
2665
  const suspended = prepared.suspendRef.token !== undefined && prepared.suspendRef.gate !== undefined;
2644
2666
  const reviewPaused = prepared.reviewRef.token !== undefined && prepared.reviewRef.gate !== undefined;
2645
2667
  const durablyPaused = suspended || reviewPaused;
@@ -3198,12 +3220,12 @@ export class Runner {
3198
3220
  if (outcome.gate === "policy_ask") {
3199
3221
  const boundTo = cp.pendingAction.kind === "tool_approval" ? cp.pendingAction.toolCallId : undefined;
3200
3222
  if (outcome.boundCallId !== boundTo) {
3201
- throw new CheckpointError("checkpoint.invalid_outcome", `resume boundCallId "${outcome.boundCallId}" does not match the checkpoint's pending tool call "${boundTo ?? "(none)"}" — the decision-action binding (design/80 D-1) failed; refusing to apply a decision bound to a different action`);
3223
+ throw new CheckpointError("checkpoint.invalid_outcome", `resume boundCallId "${outcome.boundCallId}" does not match the checkpoint's pending tool call "${boundTo ?? "(none)"}" — the decision-action binding (design/80 D-1) failed; refusing to apply a decision bound to a different action`, { field: "boundCallId" });
3202
3224
  }
3203
3225
  const boundHash = cp.pendingAction.kind === "tool_approval" ? cp.pendingAction.boundInputHash : undefined;
3204
3226
  if (boundHash !== undefined) {
3205
3227
  if (outcome.boundInputHash !== boundHash) {
3206
- throw new CheckpointError("checkpoint.invalid_outcome", "resume boundInputHash does not match the checkpoint's pending tool call input — the decision-action input binding (design/80 D-1 §2) failed; refusing to apply an approval bound to a different input (TOCTOU re-mint guard)");
3228
+ throw new CheckpointError("checkpoint.invalid_outcome", "resume boundInputHash does not match the checkpoint's pending tool call input — the decision-action input binding (design/80 D-1 §2) failed; refusing to apply an approval bound to a different input (TOCTOU re-mint guard)", { field: "boundInputHash" });
3207
3229
  }
3208
3230
  }
3209
3231
  else if (checkpointVersionOf(cp) >= BINDING_CHECKPOINT_VERSION) {
@@ -3520,6 +3542,14 @@ export class Runner {
3520
3542
  catch (err) {
3521
3543
  this.deps.onError?.(err, { phase: "mcp", sessionId: prepared.sessionId });
3522
3544
  }
3545
+ if (prepared.a2a !== undefined) {
3546
+ try {
3547
+ await prepared.a2a.dispose();
3548
+ }
3549
+ catch (err) {
3550
+ this.deps.onError?.(err, { phase: "a2a", sessionId: prepared.sessionId });
3551
+ }
3552
+ }
3523
3553
  return comp;
3524
3554
  }
3525
3555
  async teardownOwnedEnv(prepared) {
@@ -14,7 +14,7 @@ export declare function deferHint(description: string, max?: number): string;
14
14
  export declare function safeName(name: string): string;
15
15
  export declare function classifyDeferred(opts: {
16
16
  specs: ReadonlyArray<ToolSpec>;
17
- mcpToolNames: ReadonlyArray<string>;
17
+ protocolToolNames: ReadonlyArray<string>;
18
18
  fullTools: ReadonlyArray<ToolFingerprintInput>;
19
19
  deferMode?: "auto";
20
20
  model: Model;
@@ -35,7 +35,7 @@ export function classifyDeferred(opts) {
35
35
  if (s.defer === true && !pinned.has(s.name))
36
36
  deferred.add(s.name);
37
37
  }
38
- for (const name of opts.mcpToolNames)
38
+ for (const name of opts.protocolToolNames)
39
39
  if (!pinned.has(name))
40
40
  deferred.add(name);
41
41
  for (const name of opts.deferNames ?? [])
@@ -15,7 +15,7 @@ export declare const TOOL_SEARCH_REMINDER_CONFIG: {
15
15
  export declare const CHANGED_FILES_MAX = 20;
16
16
  export declare const CHANGED_FILES_MTIME_EPS_MS = 2000;
17
17
  export declare const ATTACHMENT_BYTE_CAP: number;
18
- export type AttachmentSource = "todo_reminder" | "task_reminder" | "tool_search_usage_reminder" | "changed_files" | "plan_mode" | "date_change" | "instructions_change" | "budget_usd" | "background_tasks" | "tools_delta" | "agent_listing" | "skills_listing" | "mcp_instructions" | "mcp_dropped_tools";
18
+ export type AttachmentSource = "todo_reminder" | "task_reminder" | "tool_search_usage_reminder" | "changed_files" | "plan_mode" | "date_change" | "instructions_change" | "workflow_size_guideline_change" | "budget_usd" | "background_tasks" | "tools_delta" | "agent_listing" | "skills_listing" | "mcp_instructions" | "mcp_dropped_tools";
19
19
  export interface AgentListingEntry {
20
20
  name: string;
21
21
  description: string;
@@ -31,7 +31,6 @@ export interface TurnAttachment {
31
31
  body: string;
32
32
  }
33
33
  export interface ListProjection {
34
- statusCounts: Record<string, number>;
35
34
  items: Array<{
36
35
  content: string;
37
36
  status: string;
@@ -47,12 +47,7 @@ export function reduceToolEnd(state, details) {
47
47
  : [];
48
48
  });
49
49
  const allDone = shaped.length > 0 && shaped.every((t) => t.status === "completed");
50
- state.todo = allDone
51
- ? { statusCounts: {}, items: [] }
52
- : {
53
- statusCounts: countStatuses(shaped),
54
- items: shaped.slice(0, PROJECTION_ITEMS_MAX),
55
- };
50
+ state.todo = allDone ? { items: [] } : { items: shaped.slice(0, PROJECTION_ITEMS_MAX) };
56
51
  }
57
52
  else if (d.type === "task") {
58
53
  const map = state.taskItems ?? (state.taskItems = new Map());
@@ -120,12 +115,6 @@ export function rebaseCadenceWindows(state, clock) {
120
115
  state.toolSearchLastUseTurn = clock;
121
116
  state.lastToolSearchReminderTurn = clock;
122
117
  }
123
- function countStatuses(items) {
124
- const counts = {};
125
- for (const t of items)
126
- counts[t.status] = (counts[t.status] ?? 0) + 1;
127
- return counts;
128
- }
129
118
  const EMPTY = Object.freeze([]);
130
119
  export function collectDueAttachments(state, inp) {
131
120
  let out;
@@ -0,0 +1,5 @@
1
+ import { type BackgroundAgentStore } from "../background-agent-store.js";
2
+ import { type ContractAssertionRunner } from "./contract-harness.js";
3
+ export declare const BACKGROUND_AGENT_CONTRACT_SCOPE = "tenant-A";
4
+ export declare function backgroundAgentStoreContract(mk: () => BackgroundAgentStore, runAssertion?: ContractAssertionRunner): Promise<void>;
5
+ export declare function backgroundAgentStoreScopesContract(mk: () => BackgroundAgentStore, runAssertion?: ContractAssertionRunner): Promise<void>;
@@ -0,0 +1,213 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { STALE_RUNNING_REAP_ATTRIBUTION, } from "../background-agent-store.js";
3
+ import { beginContract } from "./contract-harness.js";
4
+ export const BACKGROUND_AGENT_CONTRACT_SCOPE = "tenant-A";
5
+ function record(over = {}) {
6
+ return {
7
+ handle: "a0000000000000001",
8
+ scope: BACKGROUND_AGENT_CONTRACT_SCOPE,
9
+ owner: "task-1",
10
+ sessionScoped: false,
11
+ parentSessionId: "sess-1",
12
+ writerId: "writer-1",
13
+ spawnedAt: 1_000,
14
+ updatedAt: 1_000,
15
+ status: "running",
16
+ rev: 0,
17
+ ...over,
18
+ };
19
+ }
20
+ async function withStores(mk, fn) {
21
+ const live = [];
22
+ const make = () => {
23
+ const s = mk();
24
+ live.push(s);
25
+ return s;
26
+ };
27
+ try {
28
+ await fn(make);
29
+ }
30
+ finally {
31
+ for (const s of live.splice(0))
32
+ s.close?.();
33
+ }
34
+ }
35
+ async function held(store, handle, scope = BACKGROUND_AGENT_CONTRACT_SCOPE) {
36
+ const row = await store.get(handle, scope);
37
+ assert.notEqual(row, null, `row ${handle} should exist in scope ${scope}`);
38
+ return row;
39
+ }
40
+ export async function backgroundAgentStoreContract(mk, runAssertion) {
41
+ const { run: runRaw, settle } = beginContract(runAssertion);
42
+ const run = (name, fn) => runRaw(name, () => withStores(mk, fn));
43
+ const S = BACKGROUND_AGENT_CONTRACT_SCOPE;
44
+ run("put is create-once and scope-partitioned; get round-trips the row and is null cross-scope", async (make) => {
45
+ const store = make();
46
+ await store.put(record({ handle: "a1", finalOutput: "result-text" }));
47
+ await assert.rejects(store.put(record({ handle: "a1" })), (e) => e.code === "agent_record.already_exists", "a second put under the same (handle, scope) must throw the typed already_exists error");
48
+ await store.put(record({ handle: "a1", scope: "tenant-B" }));
49
+ const row = await held(store, "a1");
50
+ assert.equal(row.finalOutput, "result-text");
51
+ assert.equal(row.owner, "task-1");
52
+ assert.equal(await store.get("a1", "tenant-C"), null, "an unrelated scope must not see the row");
53
+ assert.equal(await store.get("a-missing", S), null);
54
+ });
55
+ run("put refuses a scopeless row (a row with no tenant partition would be world-readable)", async (make) => {
56
+ const store = make();
57
+ await assert.rejects(store.put(record({ scope: "" })), "an empty scope must be refused loudly, not stored");
58
+ });
59
+ run("get returns DETACHED copies: mutating a read never reaches the stored row", async (make) => {
60
+ const store = make();
61
+ await store.put(record({ handle: "a1", usage: { totalTokens: 5 } }));
62
+ const first = await held(store, "a1");
63
+ first.status = "failed";
64
+ first.usage.totalTokens = 999;
65
+ const second = await held(store, "a1");
66
+ assert.equal(second.status, "running");
67
+ assert.equal(second.usage?.totalTokens, 5);
68
+ });
69
+ run("update is a rev-CAS: stale rev loses, wrong scope loses, the winner bumps rev by EXACTLY 1", async (make) => {
70
+ const store = make();
71
+ await store.put(record({ handle: "a1" }));
72
+ const row = await held(store, "a1");
73
+ assert.equal(await store.update("a1", "tenant-B", { ...row, status: "completed" }, { rev: row.rev }), false, "wrong scope");
74
+ assert.equal(await store.update("a1", S, { ...row, status: "completed" }, { rev: row.rev + 5 }), false, "rev ahead");
75
+ assert.equal(await store.update("a-missing", S, { ...row, status: "completed" }, { rev: row.rev }), false, "missing row");
76
+ assert.equal(await store.update("a1", S, { ...row, status: "completed" }, { rev: row.rev }), true);
77
+ const after = await held(store, "a1");
78
+ assert.equal(after.status, "completed");
79
+ assert.equal(after.rev, row.rev + 1);
80
+ assert.equal(await store.update("a1", S, { ...row, status: "failed" }, { rev: row.rev }), false);
81
+ assert.equal((await held(store, "a1")).status, "completed");
82
+ });
83
+ run("update keeps the KEY authoritative: a payload naming another handle/scope cannot repartition the row", async (make) => {
84
+ const store = make();
85
+ await store.put(record({ handle: "a1" }));
86
+ const row = await held(store, "a1");
87
+ assert.equal(await store.update("a1", S, { ...row, handle: "a-other", scope: "tenant-B", status: "failed" }, { rev: row.rev }), true);
88
+ const after = await held(store, "a1");
89
+ assert.equal(after.handle, "a1", "the key's handle wins over the payload's");
90
+ assert.equal(after.scope, S, "scope is the partition key and an update must never rewrite it");
91
+ assert.equal(await store.get("a-other", "tenant-B"), null, "no second row may appear");
92
+ });
93
+ run("updateIf keeps the rev guard AND adds the field guards (status / checkpoint token / claim id)", async (make) => {
94
+ const store = make();
95
+ await store.put(record({ handle: "a1", status: "parked", parkedCheckpointToken: "cp-1" }));
96
+ const row = await held(store, "a1");
97
+ assert.equal(await store.updateIf("a1", S, { ...row, status: "running" }, { rev: row.rev + 3 }), false, "stale rev");
98
+ assert.equal(await store.updateIf("a1", S, { ...row, status: "running" }, { rev: row.rev, status: "running" }), false, "status mismatch");
99
+ assert.equal(await store.updateIf("a1", S, { ...row, status: "running" }, { rev: row.rev, status: "parked", parkedCheckpointToken: "cp-OTHER" }), false, "checkpoint token mismatch");
100
+ assert.equal(await store.updateIf("a1", S, { ...row, status: "running" }, { rev: row.rev, parkedCheckpointToken: null }), false, "null must mean 'this field is absent', and it is present");
101
+ assert.equal(await store.updateIf("a1", S, { ...row, status: "running" }, { rev: row.rev, parkClaimId: "claim-1" }), false, "claim id expected but absent");
102
+ const next = { ...row, status: "running", parkClaimId: "claim-1" };
103
+ assert.equal(await store.updateIf("a1", S, next, { rev: row.rev, status: "parked", parkedCheckpointToken: "cp-1", parkClaimId: null }), true, "matching guards (incl. null-as-absent on the claim id) must win");
104
+ const after = await held(store, "a1");
105
+ assert.equal(after.status, "running");
106
+ assert.equal(after.parkClaimId, "claim-1");
107
+ assert.equal(after.rev, row.rev + 1);
108
+ });
109
+ run("listBySession anchors on (sessionScoped ? owner : parentSessionId) OR rootSessionId, newest-first", async (make) => {
110
+ const store = make();
111
+ await store.put(record({ handle: "a1", spawnedAt: 1_000, finalOutput: "SECRET", summary: "s", error: "e", recentSteps: [] }));
112
+ await store.put(record({ handle: "a2", owner: "sess-1", sessionScoped: true, parentSessionId: undefined, spawnedAt: 2_000 }));
113
+ await store.put(record({ handle: "a3", parentSessionId: "sess-mid", rootSessionId: "sess-1", spawnedAt: 3_000 }));
114
+ await store.put(record({ handle: "a4", parentSessionId: "sess-other", spawnedAt: 4_000 }));
115
+ const rows = await store.listBySession(S, "sess-1");
116
+ assert.deepEqual(rows.map((r) => r.handle), ["a3", "a2", "a1"], "newest-first by spawnedAt");
117
+ for (const row of rows) {
118
+ for (const field of ["finalOutput", "summary", "error", "recentSteps", "editedFiles"]) {
119
+ assert.equal(Object.hasOwn(row, field), false, `list projections must not carry ${field}`);
120
+ }
121
+ }
122
+ assert.deepEqual((await store.listBySession(S, "sess-1", { limit: 2 })).map((r) => r.handle), ["a3", "a2"], "limit keeps the NEWEST n");
123
+ assert.deepEqual(await store.listBySession("tenant-B", "sess-1"), [], "another tenant sees nothing");
124
+ assert.deepEqual((await store.listBySession(S, "sess-mid")).map((r) => r.handle), ["a3"], "the intermediate anchor still lists its direct child");
125
+ });
126
+ run("listBySession/listByScope filter one status; listByScope enumerates the whole tenant", async (make) => {
127
+ const store = make();
128
+ await store.put(record({ handle: "a1", status: "running", spawnedAt: 1_000 }));
129
+ await store.put(record({ handle: "a2", status: "completed", spawnedAt: 2_000 }));
130
+ await store.put(record({ handle: "a3", status: "completed", parentSessionId: "sess-other", spawnedAt: 3_000 }));
131
+ await store.put(record({ handle: "a4", status: "completed", scope: "tenant-B", spawnedAt: 4_000 }));
132
+ assert.deepEqual((await store.listByScope(S)).map((r) => r.handle), ["a3", "a2", "a1"], "scope-wide, newest-first");
133
+ assert.deepEqual((await store.listByScope(S, { status: "completed" })).map((r) => r.handle), ["a3", "a2"]);
134
+ assert.deepEqual((await store.listByScope(S, { status: "completed", limit: 1 })).map((r) => r.handle), ["a3"]);
135
+ assert.deepEqual((await store.listBySession(S, "sess-1", { status: "completed" })).map((r) => r.handle), ["a2"]);
136
+ assert.deepEqual((await store.listByScope("tenant-B")).map((r) => r.handle), ["a4"], "tenants are separate ledgers");
137
+ });
138
+ run("delete is row-precise and CONDITIONAL when a rev is supplied (a concurrent revival must win)", async (make) => {
139
+ const store = make();
140
+ await store.put(record({ handle: "a1", status: "completed" }));
141
+ await store.put(record({ handle: "a2", status: "completed" }));
142
+ const row = await held(store, "a1");
143
+ assert.equal(await store.delete("a1", S, { rev: row.rev + 1 }), false, "a stale rev must not delete");
144
+ assert.notEqual(await store.get("a1", S), null);
145
+ assert.equal(await store.delete("a1", "tenant-B"), false, "cross-scope delete is a miss");
146
+ assert.equal(await store.delete("a1", S, { rev: row.rev }), true);
147
+ assert.equal(await store.get("a1", S), null);
148
+ assert.equal(await store.delete("a1", S), false, "deleting a gone row reports false, not true");
149
+ assert.equal(await store.delete("a2", S), true, "an unconditional delete still works");
150
+ });
151
+ run("reap with NO policy is a no-op (retention is always explicit)", async (make) => {
152
+ const store = make();
153
+ await store.put(record({ handle: "a1", status: "completed", settledAt: 1_000 }));
154
+ assert.equal(await store.reap(S, 100_000), 0);
155
+ assert.equal(await store.reap(S, 100_000, {}), 0);
156
+ assert.notEqual(await store.get("a1", S), null);
157
+ });
158
+ run("reap deletes terminal rows by age and by keep-bound; running and parked rows survive BOTH", async (make) => {
159
+ const store = make();
160
+ await store.put(record({ handle: "a1", status: "completed", spawnedAt: 1_000, settledAt: 1_000 }));
161
+ await store.put(record({ handle: "a2", status: "running", spawnedAt: 1_000, updatedAt: 1_000 }));
162
+ await store.put(record({ handle: "a3", status: "parked", spawnedAt: 1_000, updatedAt: 1_000, parkedCheckpointToken: "cp-1" }));
163
+ assert.equal(await store.reap(S, 100_000, { maxAgeMs: 10 }), 1, "only the terminal row ages out");
164
+ assert.equal(await store.get("a1", S), null);
165
+ assert.equal((await held(store, "a2")).status, "running");
166
+ assert.equal((await held(store, "a3")).status, "parked");
167
+ for (const i of [1, 2, 3])
168
+ await store.put(record({ handle: `b${i}`, status: "completed", spawnedAt: 1_000 + i, settledAt: 2_000 + i }));
169
+ assert.equal(await store.reap(S, 100_000, { keep: 2 }), 1);
170
+ assert.deepEqual((await store.listByScope(S, { status: "completed" })).map((r) => r.handle), ["b3", "b2"]);
171
+ assert.notEqual(await store.get("a2", S), null);
172
+ assert.notEqual(await store.get("a3", S), null);
173
+ });
174
+ run("reap's stale-running arm flips to a terminal `failed` WITH the shared attribution, and never overwrites real failure text", async (make) => {
175
+ const store = make();
176
+ await store.put(record({ handle: "a1", status: "running", updatedAt: 1_000 }));
177
+ await store.put(record({ handle: "a2", status: "running", updatedAt: 1_000, error: "the child reported a real failure" }));
178
+ assert.equal(await store.reap(S, 100_000, { staleRunningMaxAgeMs: 50_000 }), 2);
179
+ const flipped = await held(store, "a1");
180
+ assert.equal(flipped.status, "failed", "an interrupted host's row settles as a terminal, not a perpetual running");
181
+ assert.equal(flipped.stoppedBy, "system");
182
+ assert.equal(flipped.settledAt, 100_000);
183
+ assert.equal(flipped.updatedAt, 100_000);
184
+ assert.equal(flipped.error, STALE_RUNNING_REAP_ATTRIBUTION);
185
+ assert.equal(flipped.summary, STALE_RUNNING_REAP_ATTRIBUTION);
186
+ assert.equal((await held(store, "a2")).error, "the child reported a real failure");
187
+ await store.put(record({ handle: "a3", status: "running", updatedAt: 99_000 }));
188
+ assert.equal(await store.reap(S, 100_000, { staleRunningMaxAgeMs: 50_000 }), 0);
189
+ assert.equal((await held(store, "a3")).status, "running");
190
+ });
191
+ run("reap counts each ROW once even when the same sweep flips it AND removes it", async (make) => {
192
+ const store = make();
193
+ await store.put(record({ handle: "a1", status: "running", spawnedAt: 1_000, updatedAt: 1_000 }));
194
+ assert.equal(await store.reap(S, 100_000, { staleRunningMaxAgeMs: 50_000, keep: 0 }), 1, "one row changed, so the count is 1");
195
+ assert.equal(await store.get("a1", S), null, "and it really is gone (flipped, then trimmed)");
196
+ });
197
+ await settle();
198
+ }
199
+ export async function backgroundAgentStoreScopesContract(mk, runAssertion) {
200
+ const { run: runRaw, settle } = beginContract(runAssertion);
201
+ const run = (name, fn) => runRaw(name, () => withStores(mk, fn));
202
+ run("listScopes enumerates the scopes that currently HOLD rows, deduplicated and sorted", async (make) => {
203
+ const store = make();
204
+ assert.equal(typeof store.listScopes, "function", "this layer is only for backends that implement listScopes");
205
+ await store.put(record({ handle: "a1", scope: "tenant-B" }));
206
+ await store.put(record({ handle: "a2", scope: "tenant-A" }));
207
+ await store.put(record({ handle: "a3", scope: "tenant-A" }));
208
+ assert.deepEqual(await store.listScopes(), ["tenant-A", "tenant-B"], "deduplicated and sorted — a retention loop drives itself off this");
209
+ await store.delete("a1", "tenant-B");
210
+ assert.deepEqual(await store.listScopes(), ["tenant-A"]);
211
+ });
212
+ await settle();
213
+ }
@@ -1,5 +1,5 @@
1
1
  import { type BackgroundAgentRecord, type BackgroundAgentStore } from "./background-agent-store.js";
2
- import { type StopSource, type TaskAccess, type UnifiedTaskResult, type BackgroundAgentTaskHandle, type DurableAgentCore, type ParkedClaimTicket, type RegisterBackgroundAgentInput } from "./task-registry-shared.js";
2
+ import { type StopSource, type TaskAccess, type TaskRetrievalStatus, type UnifiedTaskOutput, type UnifiedTaskResult, type BackgroundAgentTaskHandle, type DurableAgentCore, type ParkedClaimTicket, type RegisterBackgroundAgentInput } from "./task-registry-shared.js";
3
3
  import { type ToolResultStore } from "./tool-result-store.js";
4
4
  export declare function ensureDurableHeartbeatLane(core: DurableAgentCore): void;
5
5
  export declare function durableAgentWriteLane(handle: BackgroundAgentTaskHandle, patch: Partial<BackgroundAgentRecord>, clear?: readonly (keyof BackgroundAgentRecord)[]): void;
@@ -126,6 +126,19 @@ export declare function notFoundRunningAgentsTail(footer: {
126
126
  named: string[];
127
127
  background: string[];
128
128
  }): string;
129
+ export interface AgentPollDetailsInput {
130
+ taskId: string;
131
+ status: UnifiedTaskOutput["status"];
132
+ retrievalStatus: TaskRetrievalStatus;
133
+ seq?: number;
134
+ stoppedBy?: StopSource;
135
+ error?: string;
136
+ errorCode?: string;
137
+ errorRetryable?: boolean;
138
+ resultIsPartial?: boolean;
139
+ completionId?: string;
140
+ }
141
+ export declare function buildAgentPollDetails(input: AgentPollDetailsInput): UnifiedTaskOutput;
129
142
  export declare function serveDurableAgentRowLane(row: BackgroundAgentRecord): UnifiedTaskResult;
130
143
  export declare function spillClippedAgentResult(handle: BackgroundAgentTaskHandle, full: string, clipped: string, store: ToolResultStore | undefined, sessionId: string | undefined): Promise<string>;
131
144
  export declare function pollBackgroundAgentLane(handle: BackgroundAgentTaskHandle, deadline?: number, signal?: AbortSignal, oneShot?: boolean, store?: ToolResultStore, sessionId?: string): Promise<UnifiedTaskResult>;
@@ -1008,7 +1008,7 @@ export function notFoundRunningAgentsTail(footer) {
1008
1008
  return ((footer.named.length > 0 ? `. Running named agents: ${footer.named.join(", ")}` : "") +
1009
1009
  (footer.background.length > 0 ? `. Running background agents: ${footer.background.join(", ")}` : ""));
1010
1010
  }
1011
- function buildAgentPollDetails(input) {
1011
+ export function buildAgentPollDetails(input) {
1012
1012
  const failed = input.status === "failed";
1013
1013
  return {
1014
1014
  task_id: input.taskId,
@@ -1016,7 +1016,7 @@ function buildAgentPollDetails(input) {
1016
1016
  status: input.status,
1017
1017
  retrieval_status: input.retrievalStatus,
1018
1018
  ...(input.seq !== undefined ? { seq: input.seq } : {}),
1019
- ...(input.status === "killed" && input.stoppedBy !== undefined ? { stoppedBy: input.stoppedBy } : {}),
1019
+ ...(input.stoppedBy !== undefined ? { stoppedBy: input.stoppedBy } : {}),
1020
1020
  ...(failed && input.error !== undefined ? { error: delimitUntrusted("agent error", boundedRedactedSummary(input.error, 300)) } : {}),
1021
1021
  ...(failed && input.errorCode !== undefined ? { errorCode: input.errorCode } : {}),
1022
1022
  ...(failed && input.errorRetryable !== undefined ? { retryable: input.errorRetryable } : {}),
@@ -197,6 +197,19 @@ export interface McpServerSpec {
197
197
  irreversibility?: "always" | "never";
198
198
  }>;
199
199
  }
200
+ export interface A2aServerSpec {
201
+ name: string;
202
+ url: string;
203
+ cardUrl?: string;
204
+ headers?: Record<string, string>;
205
+ principalHeader?: string;
206
+ allowSkills?: string[];
207
+ toolAxes?: Record<string, {
208
+ effect?: ToolEffect;
209
+ egress?: boolean;
210
+ irreversibility?: "always" | "never";
211
+ }>;
212
+ }
200
213
  export interface McpElicitRequest {
201
214
  server: string;
202
215
  message: string;
@@ -296,6 +309,7 @@ export interface TaskSpec {
296
309
  enableFork?: boolean;
297
310
  shellGate?: "off" | "always" | "classify";
298
311
  mcp?: McpServerSpec[];
312
+ a2a?: A2aServerSpec[];
299
313
  skills?: SkillSpec[];
300
314
  backgroundScope?: "task" | "session";
301
315
  envFacts?: {
@@ -319,6 +333,7 @@ export interface TaskSpec {
319
333
  };
320
334
  callCapByDeadline?: false;
321
335
  gracefulFinalize?: false;
336
+ brainCallGuardrailMs?: import("../brain/timeout.js").BrainCallGuardrailKnob;
322
337
  };
323
338
  configOverrides?: ReadonlyArray<import("../config/catalog.js").ConfigOverrideDeclaration>;
324
339
  forwardSubagentEvents?: boolean;
@@ -562,7 +577,7 @@ export type TaskEvent = ({
562
577
  reason?: string;
563
578
  } & TaskEventIdentity) | ({
564
579
  type: "steering_injected";
565
- source: "deadline_nudge" | "finalize" | "todo_reminder" | "task_reminder" | "tool_search_usage_reminder" | "changed_files" | "plan_mode" | "date_change" | "instructions_change" | "budget_usd" | "background_tasks" | "tools_delta" | "agent_listing" | "skills_listing" | "mcp_instructions" | "mcp_dropped_tools";
580
+ source: "deadline_nudge" | "finalize" | "todo_reminder" | "task_reminder" | "tool_search_usage_reminder" | "changed_files" | "plan_mode" | "date_change" | "instructions_change" | "workflow_size_guideline_change" | "budget_usd" | "background_tasks" | "tools_delta" | "agent_listing" | "skills_listing" | "mcp_instructions" | "mcp_dropped_tools";
566
581
  preview: string;
567
582
  } & TaskEventIdentity) | ({
568
583
  type: "diagnostics";
@@ -740,6 +755,7 @@ export interface RunnerDeps {
740
755
  builtinWorkflows?: boolean;
741
756
  onWorkflowAgentSpawn?: (handle: import("../orchestration/workflow.js").WorkflowAgentHandle) => void;
742
757
  maxSuspends?: number;
758
+ brainCallGuardrailMs?: import("../brain/timeout.js").BrainCallGuardrailKnob;
743
759
  now?: () => number;
744
760
  memoryBackend?: import("./memory-engine/types.js").MemoryBackend;
745
761
  memoryEngineDir?: string;
@@ -772,7 +788,7 @@ export interface RunnerDeps {
772
788
  hooks?: import("./hooks.js").Hooks;
773
789
  allowImageUrl?: (url: string) => boolean;
774
790
  onError?: (err: unknown, context: {
775
- phase: "compaction" | "prompt-cache" | "prompt-constitution" | "degraded" | "config" | "memory" | "mcp" | "interrupt-reconcile" | "suggestions" | "rewind" | "hook";
791
+ phase: "compaction" | "prompt-cache" | "prompt-constitution" | "degraded" | "config" | "memory" | "mcp" | "a2a" | "interrupt-reconcile" | "suggestions" | "rewind" | "hook";
776
792
  sessionId: string;
777
793
  classification?: string;
778
794
  }) => void;
@@ -19,7 +19,6 @@ export declare function decideSpoolReclaim(args: {
19
19
  reclaim: false;
20
20
  } | {
21
21
  reclaim: true;
22
- live: boolean;
23
22
  skippedBytes: number;
24
23
  };
25
24
  export declare function openSpoolPair(base: string): {
@@ -8,7 +8,7 @@ import { createInterface } from "node:readline";
8
8
  import { ExecutionError, err, FileError, ok, toError, } from "../harness/types.js";
9
9
  import { killProcessTree, shutdownDebug } from "./kill-tree.js";
10
10
  import { scrubSecretEnv } from "../../core/secret-env.js";
11
- import { RollingTailBuffer, markLiveSpoolRotations, markTruncated, newStreamCursorState, sliceStreamIncrement, } from "../../core/exec-output-tail.js";
11
+ import { RollingTailBuffer, markTruncated, newStreamCursorState, sliceStreamIncrement, } from "../../core/exec-output-tail.js";
12
12
  import { BackgroundShellError } from "../../core/background-shell.js";
13
13
  import { SchedulerError } from "../../core/scheduler.js";
14
14
  const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
@@ -216,18 +216,14 @@ const EXEC_SPOOL_HARD_CAP_BYTES = 16 * 1024 * 1024;
216
216
  export function decideSpoolReclaim(args) {
217
217
  const rotate = args.rotateBytes ?? EXEC_SPOOL_ROTATE_BYTES;
218
218
  const hardCap = args.hardCapBytes ?? EXEC_SPOOL_HARD_CAP_BYTES;
219
- if (args.cursor < rotate)
219
+ if (args.writerLive)
220
220
  return { reclaim: false };
221
- if (args.size === args.cursor) {
222
- if (!args.writerLive)
223
- return { reclaim: true, live: false, skippedBytes: 0 };
224
- if (args.size >= hardCap)
225
- return { reclaim: true, live: true, skippedBytes: 0 };
221
+ if (args.cursor < rotate)
226
222
  return { reclaim: false };
227
- }
228
- if (args.size > args.cursor && args.size >= hardCap) {
229
- return { reclaim: true, live: args.writerLive, skippedBytes: args.size - args.cursor };
230
- }
223
+ if (args.size === args.cursor)
224
+ return { reclaim: true, skippedBytes: 0 };
225
+ if (args.size > args.cursor && args.size >= hardCap)
226
+ return { reclaim: true, skippedBytes: args.size - args.cursor };
231
227
  return { reclaim: false };
232
228
  }
233
229
  const SIGNUM = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGABRT: 6, SIGKILL: 9, SIGSEGV: 11, SIGPIPE: 13, SIGTERM: 15 };
@@ -330,8 +326,6 @@ export class NodeExecutionEnv {
330
326
  const adoptOnTimeoutRef = {};
331
327
  const pumpSpoolRef = {};
332
328
  const drainSpoolRef = {};
333
- const liveRotations = { out: 0, err: 0 };
334
- const liveRotationsTotal = () => liveRotations.out + liveRotations.err;
335
329
  const writerLive = () => !settled && child !== undefined && child.exitCode === null && child.signalCode === null;
336
330
  const armForceSettle = (result) => {
337
331
  if (forceSettleRef.current || settled)
@@ -351,7 +345,7 @@ export class NodeExecutionEnv {
351
345
  drainSpoolRef.current?.();
352
346
  const so = stdoutTail.result();
353
347
  const se = stderrTail.result();
354
- return { stdout: markTruncated(so.text, so.droppedBytes), stderr: markLiveSpoolRotations(markTruncated(se.text, se.droppedBytes), liveRotationsTotal()) };
348
+ return { stdout: markTruncated(so.text, so.droppedBytes), stderr: markTruncated(se.text, se.droppedBytes) };
355
349
  };
356
350
  const onAbort = () => {
357
351
  abortKillInFlight = true;
@@ -545,8 +539,6 @@ export class NodeExecutionEnv {
545
539
  return;
546
540
  if (d.skippedBytes > 0)
547
541
  lane.tail.recordSkippedBytes(d.skippedBytes);
548
- if (d.live)
549
- liveRotations[lane.cursor]++;
550
542
  truncateSync(lane.path, 0);
551
543
  spoolCursor[lane.cursor] = 0;
552
544
  }
@@ -583,9 +575,7 @@ export class NodeExecutionEnv {
583
575
  if (settled || !child)
584
576
  return false;
585
577
  pumpSpoolRef.current?.();
586
- const shellId = this.adoptRunningChild(child, stdoutTail.result(), stderrTail.result(), execSpoolRef.current !== undefined
587
- ? { spool: execSpoolRef.current, cursors: { out: spoolCursor.out, err: spoolCursor.err }, liveRotations: liveRotationsTotal() }
588
- : undefined);
578
+ const shellId = this.adoptRunningChild(child, stdoutTail.result(), stderrTail.result(), execSpoolRef.current !== undefined ? { spool: execSpoolRef.current, cursors: { out: spoolCursor.out, err: spoolCursor.err } } : undefined);
589
579
  if (shellId === undefined)
590
580
  return false;
591
581
  if (spoolPumpRef.current)
@@ -678,12 +668,12 @@ export class NodeExecutionEnv {
678
668
  const signum = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGABRT: 6, SIGKILL: 9, SIGSEGV: 11, SIGPIPE: 13, SIGTERM: 15 };
679
669
  settle(ok({
680
670
  stdout: markTruncated(so.text, so.droppedBytes),
681
- stderr: markLiveSpoolRotations(`${markTruncated(se.text, se.droppedBytes)}${se.text.endsWith("\n") || se.text === "" ? "" : "\n"}[process terminated by ${signal}]`, liveRotationsTotal()),
671
+ stderr: `${markTruncated(se.text, se.droppedBytes)}${se.text.endsWith("\n") || se.text === "" ? "" : "\n"}[process terminated by ${signal}]`,
682
672
  exitCode: 128 + (signum[signal] ?? 9),
683
673
  }));
684
674
  return;
685
675
  }
686
- settle(ok({ stdout: markTruncated(so.text, so.droppedBytes), stderr: markLiveSpoolRotations(markTruncated(se.text, se.droppedBytes), liveRotationsTotal()), exitCode: code ?? 0 }));
676
+ settle(ok({ stdout: markTruncated(so.text, so.droppedBytes), stderr: markTruncated(se.text, se.droppedBytes), exitCode: code ?? 0 }));
687
677
  };
688
678
  child.on("close", (code, signal) => settleFromTermination(code, signal));
689
679
  child.on("exit", (code, signal) => {
@@ -1051,7 +1041,6 @@ export class NodeExecutionEnv {
1051
1041
  ...(handover !== undefined
1052
1042
  ? { spool: { outPath: handover.spool.outPath, errPath: handover.spool.errPath, fileCursor: { ...handover.cursors }, ephemeral: true } }
1053
1043
  : {}),
1054
- ...(handover !== undefined && (handover.liveRotations ?? 0) > 0 ? { liveRotations: handover.liveRotations } : {}),
1055
1044
  };
1056
1045
  const seedOut = Buffer.from(stdoutSoFar.text, "utf8");
1057
1046
  const seedErr = Buffer.from(stderrSoFar.text, "utf8");
@@ -1241,14 +1230,9 @@ export class NodeExecutionEnv {
1241
1230
  const out = sliceStreamIncrement(entry.stdout, terminal);
1242
1231
  const er = sliceStreamIncrement(entry.stderr, terminal);
1243
1232
  const droppedBeforeCursor = out.droppedBeforeCursor + er.droppedBeforeCursor;
1244
- let stderrInc = er.inc;
1245
- if (terminal && (entry.liveRotations ?? 0) > 0 && entry.liveRotationsDisclosed !== true) {
1246
- entry.liveRotationsDisclosed = true;
1247
- stderrInc = markLiveSpoolRotations(er.inc, entry.liveRotations ?? 0);
1248
- }
1249
1233
  return ok({
1250
1234
  stdout: out.inc,
1251
- stderr: stderrInc,
1235
+ stderr: er.inc,
1252
1236
  status: entry.status,
1253
1237
  ...(entry.exitCode !== undefined ? { exitCode: entry.exitCode } : {}),
1254
1238
  ...(droppedBeforeCursor > 0 ? { truncated: true, bytesDroppedBeforeCursor: droppedBeforeCursor } : {}),
@@ -1320,8 +1304,6 @@ export class NodeExecutionEnv {
1320
1304
  stream.tail.recordSkippedBytes(d.skippedBytes);
1321
1305
  stream.totalBytes += d.skippedBytes;
1322
1306
  }
1323
- if (d.live)
1324
- entry.liveRotations = (entry.liveRotations ?? 0) + 1;
1325
1307
  truncateSync(path, 0);
1326
1308
  spool.fileCursor[lane] = 0;
1327
1309
  }