@sema-agent/core 2.9.0 → 2.11.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.
@@ -8,6 +8,9 @@ import { createSubagentResume } from "./subagent.js";
8
8
  export const SEND_MESSAGE_TOOL_NAME = "SendMessage";
9
9
  let uplinkSeqGlobal = Date.now();
10
10
  const UPLINK_RESULT_MAX = 8000;
11
+ function clipCarrierMessage(text) {
12
+ return text.length > UPLINK_RESULT_MAX ? `${text.slice(0, UPLINK_RESULT_MAX)}\n[message truncated: ${text.length} chars total]` : text;
13
+ }
11
14
  export const SEND_MESSAGE_SUMMARY_MAX = 200;
12
15
  export function clipSendMessageSummary(raw) {
13
16
  return raw.length > SEND_MESSAGE_SUMMARY_MAX
@@ -89,6 +92,7 @@ export function createSendMessageTool(opts) {
89
92
  isError: true,
90
93
  };
91
94
  }
95
+ const summary = clipSendMessageSummary(summaryArg);
92
96
  const senderId = ctx.taskId ?? opts.owner;
93
97
  const [parentTaskId, parentSessionId] = ctx.parentTaskId !== undefined ? [ctx.parentTaskId, ctx.parentSessionId] : [opts.parentTaskId, opts.parentSessionId];
94
98
  const senderName = ctx.spawnedAgentName ?? opts.senderName;
@@ -102,14 +106,12 @@ export function createSendMessageTool(opts) {
102
106
  }
103
107
  if (normalizeAgentName(to) === "main") {
104
108
  if (opts.uplink && senderId !== undefined) {
105
- const uplinkSummaryRaw = typeof a.summary === "string" && a.summary.trim() !== "" ? clipSendMessageSummary(a.summary.trim()) : message.slice(0, 80);
106
- const fromLabel = senderName ?? senderId;
107
109
  try {
108
110
  opts.uplink({
109
111
  task_id: senderId,
110
112
  task_type: "background_agent",
111
113
  status: "event",
112
- summary: `message from ${fromLabel}: ${uplinkSummaryRaw}`,
114
+ summary: `message from ${senderLabel}: ${summary}`,
113
115
  result: message.length > UPLINK_RESULT_MAX ? `${message.slice(0, UPLINK_RESULT_MAX)}\n[uplink truncated: ${message.length} chars total — read the agent's transcript for the rest]` : message,
114
116
  seq: ++uplinkSeqGlobal,
115
117
  }, { priority: "next" });
@@ -270,11 +272,9 @@ export function createSendMessageTool(opts) {
270
272
  isError: true,
271
273
  };
272
274
  }
273
- const clipped = message.length > UPLINK_RESULT_MAX ? `${message.slice(0, UPLINK_RESULT_MAX)}\n[message truncated: ${message.length} chars total]` : message;
274
- const t3Summary = typeof a.summary === "string" && a.summary.trim() !== "" ? clipSendMessageSummary(a.summary.trim()) : undefined;
275
- const t3From = senderLabel;
275
+ const clipped = clipCarrierMessage(message);
276
276
  try {
277
- await opts.mailbox.append(scope, handle, { from: t3From, content: t3Summary !== undefined ? `[${t3Summary}] ${clipped}` : clipped, sentAt: now });
277
+ await opts.mailbox.append(scope, handle, { from: senderLabel, content: `[${summary}] ${clipped}`, sentAt: now });
278
278
  }
279
279
  catch (e) {
280
280
  await rollback();
@@ -418,17 +418,13 @@ export function createSendMessageTool(opts) {
418
418
  }
419
419
  if (row.status === "running" || row.status === "pending") {
420
420
  return await withTargetLane(targetLaneKey(access.scope, targetId), async () => {
421
- const s2Summary = typeof a.summary === "string" && a.summary.trim() !== "" ? clipSendMessageSummary(a.summary.trim()) : undefined;
422
- const fromLabel = senderLabel;
423
- const s2Clipped = message.length > UPLINK_RESULT_MAX
424
- ? `${message.slice(0, UPLINK_RESULT_MAX)}\n[message truncated: ${message.length} chars total]`
425
- : message;
426
- const teammateXml = frameTeammateMessage({ from: fromLabel, ...(s2Summary !== undefined ? { summary: s2Summary } : {}), text: s2Clipped });
421
+ const s2Clipped = clipCarrierMessage(message);
422
+ const teammateXml = frameTeammateMessage({ from: senderLabel, summary, text: s2Clipped });
427
423
  const delivered = await opts.registry.deliverToRunningAgent(targetId, resolvedAccess, {
428
424
  task_id: senderLabel,
429
425
  task_type: "background_agent",
430
426
  status: "event",
431
- summary: `message from ${fromLabel}${s2Summary !== undefined ? `: ${s2Summary}` : ""}`,
427
+ summary: `message from ${senderLabel}: ${summary}`,
432
428
  result: teammateXml,
433
429
  seq: ++uplinkSeqGlobal,
434
430
  }, { priority: "next" });
@@ -440,7 +436,7 @@ export function createSendMessageTool(opts) {
440
436
  : `Message queued for delivery to ${who} at its next turn. If it finishes before reading it, the message may not survive — you will be notified of its completion either way; resend then if it went unanswered. Continue with other work; do not poll.`;
441
437
  return {
442
438
  content: receiptText,
443
- details: { type: "send-message", status: "delivered_running", disposition: delivered.disposition, to, task_id: targetId, ...(s2Summary !== undefined ? { summary: s2Summary } : {}) },
439
+ details: { type: "send-message", status: "delivered_running", disposition: delivered.disposition, to, task_id: targetId, summary },
444
440
  };
445
441
  }
446
442
  if (delivered.reason === "no_channel") {
@@ -531,16 +527,15 @@ export function createSendMessageTool(opts) {
531
527
  ...(row.rootSessionId !== undefined ? { rowRootSessionId: row.rootSessionId } : {}),
532
528
  ...(opts.notify ? { currentParentNotify: opts.notify } : {}),
533
529
  });
534
- const summary = typeof a.summary === "string" && a.summary.trim() !== "" ? clipSendMessageSummary(a.summary.trim()) : undefined;
535
- const fromPrefix = parentTaskId !== undefined ? `(message from teammate "${senderName ?? senderId ?? "unknown"}")\n` : "";
530
+ const fromPrefix = senderIsChild ? `(message from teammate "${senderLabel}")\n` : "";
536
531
  try {
537
- const safeSummary = summary !== undefined ? escapeEnvelopeTag(TEAMMATE_MESSAGE_TAG, summary) : undefined;
532
+ const safeSummary = escapeEnvelopeTag(TEAMMATE_MESSAGE_TAG, summary);
538
533
  const safeMessage = escapeEnvelopeTag(TEAMMATE_MESSAGE_TAG, message);
539
- const marker = await resume(`${fromPrefix}${safeSummary ? `[${safeSummary}] ${safeMessage}` : safeMessage}`);
534
+ const marker = await resume(`${fromPrefix}[${safeSummary}] ${safeMessage}`);
540
535
  return {
541
536
  content: `Message sent — ${who} resumed in the background with its prior context intact (correlation marker [${marker}]).\n` +
542
537
  `You will be notified automatically when it completes; its reply will carry [${marker}]. Continue with other work — do not poll.`,
543
- details: { type: "send-message", status: "resumed", to, task_id: targetId, marker, ...(summary !== undefined ? { summary } : {}) },
538
+ details: { type: "send-message", status: "resumed", to, task_id: targetId, marker, summary },
544
539
  };
545
540
  }
546
541
  catch (e) {
@@ -17,7 +17,7 @@ import { addWorktree } from "../core/git-worktree-env.js";
17
17
  import { shellQuote } from "../tools/fs/search.js";
18
18
  import { BG_AGENT_REAP_STOP_ERROR } from "../core/task-registry.js";
19
19
  import { extractErrorCode } from "../brain/errors.js";
20
- import { FORK_DEFAULT_MAX_TURNS, SESSION_BG_DEFAULT_TIMEOUT_SEC, RETAIN_DEFAULT_TTL_MS, RETAIN_DEFAULT_MAX } from "../config/defaults.js";
20
+ import { FORK_DEFAULT_MAX_TURNS, SESSION_BG_DEFAULT_TIMEOUT_SEC, RETAIN_DEFAULT_TTL_MS, RETAIN_DEFAULT_MAX, RUNNING_AGENT_OBSERVE_EVERY_BEATS } from "../config/defaults.js";
21
21
  export { FORK_DEFAULT_MAX_TURNS, SESSION_BG_DEFAULT_TIMEOUT_SEC, RETAIN_DEFAULT_TTL_MS, RETAIN_DEFAULT_MAX };
22
22
  import { SUBAGENT_RESUME_CAP, SubagentRetainLedger, getOrCreateSessionRetainLedger, ensureSessionReapHook, createResumePrompt, } from "./retain-ledger.js";
23
23
  import { recordRosterSpawn } from "./roster-store.js";
@@ -227,6 +227,21 @@ function errorKindClause(c) {
227
227
  const FAILED_SESSION_RETAIN_TTL_MS = 15 * 60 * 1000;
228
228
  const PARTIAL_FINDINGS_MAX_CHARS = 1200;
229
229
  const BG_NOTIFY_DRAIN_WINDOW_MS = 2_000;
230
+ function createBgActivityBeat(parentToolCallId, emitTick) {
231
+ let beats = 0;
232
+ let starts = 0;
233
+ return (e) => {
234
+ if (e.type !== "tool_start" && e.type !== "tool_end")
235
+ return;
236
+ if (parentToolCallId === undefined || e.parentToolCallId !== parentToolCallId)
237
+ return;
238
+ if (e.type === "tool_start")
239
+ starts += 1;
240
+ beats += 1;
241
+ if (beats === 1 || beats % RUNNING_AGENT_OBSERVE_EVERY_BEATS === 0)
242
+ emitTick(starts);
243
+ };
244
+ }
230
245
  function markerFragment() {
231
246
  return uuidv7().replace(/-/g, "").slice(-12);
232
247
  }
@@ -351,6 +366,25 @@ export function createSubagentResume(deps) {
351
366
  }
352
367
  }
353
368
  reviveStartedAt = Date.now();
369
+ const reviveActivityBeat = createBgActivityBeat(deps.parentToolCallId, (toolStarts) => {
370
+ if (reviveEmit === undefined)
371
+ return;
372
+ const currentAction = resumeStepRecorder.currentAction();
373
+ const currentTool = resumeStepRecorder.currentActionStructured();
374
+ reviveEmit({
375
+ kind: "tick",
376
+ taskId: deps.taskId,
377
+ sessionScoped: deps.sessionScoped === true,
378
+ ...(deps.rowAgentType !== undefined ? { agentType: deps.rowAgentType } : {}),
379
+ transcriptId: entry.childSessionId,
380
+ sessionId: entry.childSessionId,
381
+ ...(deps.parentToolCallId !== undefined ? { parentToolCallId: deps.parentToolCallId } : {}),
382
+ progressTaskId: entry.childSessionId,
383
+ ...(currentAction !== undefined ? { currentAction } : {}),
384
+ ...(currentTool !== undefined ? { currentTool } : {}),
385
+ usage: { toolUses: toolStarts },
386
+ });
387
+ });
354
388
  stream = childRunner.runTaskStream(resumeSpec, undefined, {
355
389
  ...entry.internalsSnapshot,
356
390
  ...(true
@@ -362,6 +396,7 @@ export function createSubagentResume(deps) {
362
396
  }
363
397
  catch {
364
398
  }
399
+ reviveActivityBeat(e);
365
400
  if (reviveEmit !== undefined && e.type === "task_progress") {
366
401
  reviveEmit({
367
402
  kind: "tick",
@@ -1337,8 +1372,10 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1337
1372
  const editedFiles = stepRecorder.editedFiles();
1338
1373
  return { ...(recentSteps ? { recentSteps } : {}), ...(editedFiles ? { editedFiles } : {}) };
1339
1374
  };
1375
+ const treeScope = reviveClaim?.row.scope ?? ctx.principal ?? opts.background?.scope;
1340
1376
  const childInternals = {
1341
1377
  ...(inheritedManifestScope ? { inheritedManifestScope } : {}),
1378
+ ...(treeScope !== undefined ? { registryScope: treeScope } : {}),
1342
1379
  ...(ctx.inheritedGateForChildren ? { inheritedGate: ctx.inheritedGateForChildren() } : {}),
1343
1380
  ...(childDefaultPersona !== undefined ? { defaultSystemPrompt: childDefaultPersona } : {}),
1344
1381
  isDelegatedChild: true,
@@ -1597,7 +1634,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1597
1634
  }
1598
1635
  const shortDesc = `fork: ${(typeof a.description === "string" && a.description.trim() ? a.description.trim() : prompt).slice(0, 180)}`;
1599
1636
  const bgOwner = sessionScopedBg ? ctx.sessionId : ctx.taskId ?? bg.owner;
1600
- const bgScope = ctx.principal ?? bg.scope;
1637
+ const bgScope = treeScope;
1601
1638
  let taskId;
1602
1639
  try {
1603
1640
  taskId = bg.registry.registerBackgroundAgent({
@@ -1698,6 +1735,23 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1698
1735
  const s2ForkNotifyReady = (inject) => {
1699
1736
  bg.registry.attachAgentNotify(taskId, inject);
1700
1737
  };
1738
+ const bgForkActivityBeat = createBgActivityBeat(ctx.toolCallId, (toolStarts) => {
1739
+ const currentAction = stepRecorder.currentAction();
1740
+ const currentTool = stepRecorder.currentActionStructured();
1741
+ sinkEmit({
1742
+ kind: "tick",
1743
+ taskId,
1744
+ sessionScoped: sessionScopedBg === true,
1745
+ transcriptId: forkedId,
1746
+ sessionId: forkedId,
1747
+ parentToolCallId: ctx.toolCallId,
1748
+ progressTaskId: forkedId,
1749
+ agentType: spawnAgentType,
1750
+ ...(currentAction !== undefined ? { currentAction } : {}),
1751
+ ...(currentTool !== undefined ? { currentTool } : {}),
1752
+ usage: { toolUses: toolStarts },
1753
+ });
1754
+ });
1701
1755
  const bgForkInternals = bgSink
1702
1756
  ? {
1703
1757
  ...forkInternals,
@@ -1708,6 +1762,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1708
1762
  }
1709
1763
  catch {
1710
1764
  }
1765
+ bgForkActivityBeat(e);
1711
1766
  if (e.type === "task_progress") {
1712
1767
  const currentAction = stepRecorder.currentAction();
1713
1768
  const currentTool = stepRecorder.currentActionStructured();
@@ -1764,6 +1819,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1764
1819
  ? classifySubagentError({ status: "failed", ...(child.errorCode !== undefined ? { errorCode: child.errorCode } : {}), ...(child.errorMessage !== undefined ? { errorMessage: child.errorMessage } : {}) })
1765
1820
  : undefined;
1766
1821
  const settledBg = bg.registry.settleBackgroundAgent(taskId, {
1822
+ cycle: 0,
1767
1823
  status: okBg ? "completed" : reapedBg ? "killed" : "failed",
1768
1824
  ...resultSettleFields(child.result),
1769
1825
  ...(!okBg ? { error: reapedBg ? (collateralBg ? BG_AGENT_COLLATERAL_REAP_REASON : BG_AGENT_REAP_STOP_ERROR) : child.errorMessage ?? String(child.status) } : {}),
@@ -1835,6 +1891,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1835
1891
  const errCodeForkReject = killed ? undefined : extractErrorCode(msgFork);
1836
1892
  const errClassForkReject = killed ? undefined : classifySubagentError({ status: "failed", errorMessage: msgFork });
1837
1893
  const settledBg = bg.registry.settleBackgroundAgent(taskId, {
1894
+ cycle: 0,
1838
1895
  status: killed ? "killed" : "failed",
1839
1896
  error: killed ? BG_AGENT_REAP_STOP_ERROR : msgFork,
1840
1897
  ...(errCodeForkReject !== undefined ? { errorCode: errCodeForkReject } : {}),
@@ -1982,7 +2039,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1982
2039
  }
1983
2040
  const shortDesc = reviveRow?.description ?? String(a.description ?? "sub-agent").slice(0, 200);
1984
2041
  const bgOwner = reviveRow !== undefined ? reviveRow.owner : sessionScopedBg ? ctx.sessionId : ctx.taskId ?? bg.owner;
1985
- const bgScope = reviveRow !== undefined ? reviveRow.scope : ctx.principal ?? bg.scope;
2042
+ const bgScope = treeScope;
1986
2043
  let taskId;
1987
2044
  try {
1988
2045
  taskId = bg.registry.registerBackgroundAgent(reviveRow !== undefined
@@ -2157,6 +2214,23 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2157
2214
  bg.registry.finalizeParkedResume(taskId);
2158
2215
  reviveAttachedResolve?.();
2159
2216
  };
2217
+ const bgActivityBeat = createBgActivityBeat(ctx.toolCallId, (toolStarts) => {
2218
+ const currentAction = stepRecorder.currentAction();
2219
+ const currentTool = stepRecorder.currentActionStructured();
2220
+ sinkEmit({
2221
+ kind: "tick",
2222
+ taskId,
2223
+ sessionScoped: sessionScopedBg === true,
2224
+ transcriptId: bgChildSessionId,
2225
+ sessionId: bgChildSessionId,
2226
+ parentToolCallId: ctx.toolCallId,
2227
+ progressTaskId: bgChildSessionId,
2228
+ agentType: spawnAgentType,
2229
+ ...(currentAction !== undefined ? { currentAction } : {}),
2230
+ ...(currentTool !== undefined ? { currentTool } : {}),
2231
+ usage: { toolUses: toolStarts },
2232
+ });
2233
+ });
2160
2234
  const bgInternals = bgSink
2161
2235
  ? {
2162
2236
  ...childInternals,
@@ -2167,6 +2241,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2167
2241
  }
2168
2242
  catch {
2169
2243
  }
2244
+ bgActivityBeat(e);
2170
2245
  if (e.type === "task_progress") {
2171
2246
  const currentAction = stepRecorder.currentAction();
2172
2247
  const currentTool = stepRecorder.currentActionStructured();
@@ -2400,6 +2475,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2400
2475
  ? classifySubagentError({ status: "failed", ...(child.errorCode !== undefined ? { errorCode: child.errorCode } : {}), ...(child.errorMessage !== undefined ? { errorMessage: child.errorMessage } : {}) })
2401
2476
  : undefined;
2402
2477
  const settled = bg.registry.settleBackgroundAgent(taskId, {
2478
+ cycle: 0,
2403
2479
  status: ok ? "completed" : reaped ? "killed" : "failed",
2404
2480
  seq: seqAtSettle ?? 1,
2405
2481
  ...resultSettleFields(child.result),
@@ -2496,6 +2572,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2496
2572
  const errCodeBgReject = killed ? undefined : extractErrorCode(msg);
2497
2573
  const errClassBgReject = killed ? undefined : classifySubagentError({ status: "failed", errorMessage: msg });
2498
2574
  const settled = bg.registry.settleBackgroundAgent(taskId, {
2575
+ cycle: 0,
2499
2576
  status: killed ? "killed" : "failed",
2500
2577
  error: msg,
2501
2578
  seq: seqAtSettle ?? 1,
@@ -4,3 +4,4 @@ export declare const SESSION_BG_DEFAULT_TIMEOUT_SEC: number;
4
4
  export declare const RETAIN_DEFAULT_TTL_MS: number;
5
5
  export declare const RETAIN_DEFAULT_MAX = 16;
6
6
  export declare const SESSION_DEFAULT_TTL_DAYS = 7;
7
+ export declare const RUNNING_AGENT_OBSERVE_EVERY_BEATS = 4;
@@ -4,3 +4,4 @@ export const SESSION_BG_DEFAULT_TIMEOUT_SEC = 30 * 60;
4
4
  export const RETAIN_DEFAULT_TTL_MS = 30 * 60 * 1000;
5
5
  export const RETAIN_DEFAULT_MAX = 16;
6
6
  export const SESSION_DEFAULT_TTL_DAYS = 7;
7
+ export const RUNNING_AGENT_OBSERVE_EVERY_BEATS = 4;
@@ -23,8 +23,10 @@ export async function consolidateScope(scope, deps, opts = {}) {
23
23
  "(getConsolidationCursor/setConsolidationCursor) — skipped (no-op), NOT degraded to a full re-consolidation"));
24
24
  return undefined;
25
25
  }
26
- if (!supportsConsolidation(store) || typeof store.listStructuredNotes !== "function") {
27
- onWarn?.(new Error("consolidateScope: store is not id-addressable / has no manifest — skipped (no-op)"));
26
+ if (!supportsConsolidation(store) ||
27
+ typeof store.listStructuredNotes !== "function" ||
28
+ typeof store.getByIds !== "function") {
29
+ onWarn?.(new Error("consolidateScope: store is not id-addressable / has no manifest read pair — skipped (no-op)"));
28
30
  return undefined;
29
31
  }
30
32
  const release = deps.acquire ? await deps.acquire(scope) : () => { };
@@ -76,29 +76,28 @@ export function clearStaleToolResults(messages, opts) {
76
76
  }
77
77
  const keep = opts.keepRecentToolResults ?? 3;
78
78
  const compactable = opts.compactableTools ?? COMPACTABLE_TOOLS;
79
- const toolResultIdx = messages.flatMap((m, i) => isToolResult(m) && !isCleared(m) && compactable.has(m.toolName ?? "") ? [i] : []);
80
- const clearable = toolResultIdx.slice(0, Math.max(0, toolResultIdx.length - keep));
79
+ const toolResultCandidates = messages.flatMap((m, i) => isToolResult(m) && !isCleared(m) && compactable.has(m.toolName) ? [{ idx: i, target: m }] : []);
80
+ const clearable = toolResultCandidates.slice(0, Math.max(0, toolResultCandidates.length - keep));
81
81
  if (clearable.length === 0) {
82
82
  return messages;
83
83
  }
84
84
  const out = messages.slice();
85
85
  let current = total;
86
- for (const idx of clearable) {
86
+ for (const { idx, target } of clearable) {
87
87
  if (current <= opts.budgetTokens) {
88
88
  break;
89
89
  }
90
- const before = estimateTokens(out[idx], cpt);
91
- const msg = out[idx];
92
- const rawContent = Array.isArray(msg.content) ? msg.content : [];
90
+ const before = estimateTokens(target, cpt);
91
+ const rawContent = Array.isArray(target.content) ? target.content : [];
93
92
  let ref;
94
93
  if (opts.offload) {
95
94
  const fullText = rawContent
96
95
  .filter((c) => c?.type === "text" && typeof c.text === "string")
97
96
  .map((c) => c.text)
98
97
  .join("\n");
99
- if (msg.toolCallId && fullText.trim().length > 0) {
98
+ if (target.toolCallId && fullText.trim().length > 0) {
100
99
  try {
101
- ref = refNote(opts.offload.persist(msg.toolCallId, fullText));
100
+ ref = refNote(opts.offload.persist(target.toolCallId, fullText));
102
101
  }
103
102
  catch {
104
103
  ref = undefined;
@@ -107,10 +106,7 @@ export function clearStaleToolResults(messages, opts) {
107
106
  }
108
107
  const mediaBlocks = rawContent.filter((c) => c?.type !== "text");
109
108
  const marker = clearedMarker([ref, mediaBlocks.length > 0 ? mediaNote(mediaBlocks) : undefined]);
110
- const cleared = {
111
- ...out[idx],
112
- content: [{ type: "text", text: marker }],
113
- };
109
+ const cleared = { ...target, content: [{ type: "text", text: marker }] };
114
110
  out[idx] = cleared;
115
111
  current -= idx > anchorIdx ? before - estimateTokens(cleared, cpt) : 0;
116
112
  }
@@ -47,8 +47,10 @@ export interface MemoryNoteHeader {
47
47
  export interface MemoryNoteRecord extends MemoryNoteHeader {
48
48
  text: string;
49
49
  }
50
- export declare function supportsConsolidation(store: MemoryStore): boolean;
51
- export declare function supportsPeriodicConsolidation(store: MemoryStore): boolean;
50
+ export type ConsolidationCapableStore = MemoryStore & Required<Pick<MemoryStore, "searchScored" | "update" | "delete">>;
51
+ export declare function supportsConsolidation(store: MemoryStore): store is ConsolidationCapableStore;
52
+ export type PeriodicConsolidationCapableStore = MemoryStore & Required<Pick<MemoryStore, "getConsolidationCursor" | "setConsolidationCursor">>;
53
+ export declare function supportsPeriodicConsolidation(store: MemoryStore): store is PeriodicConsolidationCapableStore;
52
54
  export declare const CALLER_AUTHORED_TYPES: ReadonlySet<string>;
53
55
  export declare const DEFAULT_CALLER_AUTHORED_TYPE: MemoryNoteType;
54
56
  export type UtilityGate = (stats: unknown) => boolean;
@@ -1,3 +1,4 @@
1
1
  import type { AgentMessage } from "../internal/harness-types.js";
2
+ import type { ToolResultMessage } from "../internal/llm.js";
2
3
  export declare function messageRole(m: AgentMessage): string | undefined;
3
- export declare function isToolResult(m: AgentMessage): boolean;
4
+ export declare function isToolResult(m: AgentMessage): m is ToolResultMessage;
@@ -69,6 +69,7 @@ export interface ResultFlags {
69
69
  threw: unknown;
70
70
  model?: string;
71
71
  unpricedSpend?: boolean;
72
+ rewindNotes?: TaskResult["rewindNotes"];
72
73
  abortedForTimeout: boolean;
73
74
  abortedForTurns: boolean;
74
75
  abortedLive?: boolean;
@@ -143,5 +143,5 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
143
143
  void _internalCompaction;
144
144
  if (flags.unpricedSpend)
145
145
  delete publicStats.costMicroUsd;
146
- return { taskId, sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, checkpointToken, checkpointGate, stats: publicStats };
146
+ return { taskId, sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, checkpointToken, checkpointGate, ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), stats: publicStats };
147
147
  }
@@ -17,7 +17,7 @@ import { type CwdRef } from "../../tools/fs/index.js";
17
17
  import type { Runner } from "./runtask.js";
18
18
  import { type CheckpointGate, type CheckpointState, type CheckpointToken, type ResourceLedger, type ResourceLimitReason } from "../checkpoint-store.js";
19
19
  import type { AgentMessage, AgentTool, ExecutionEnv } from "../../internal/harness.js";
20
- import type { RunnerDeps, TaskEvent, TaskSpec, ToolActivity, ToolEffect } from "../types.js";
20
+ import type { RunnerDeps, TaskEvent, TaskResult, TaskSpec, ToolActivity, ToolEffect } from "../types.js";
21
21
  import type { RepairBundle } from "../../agents/repair-loop.js";
22
22
  export declare const DEFAULT_IRREVERSIBLE_SCOPE = "irreversible";
23
23
  export declare function checkpointScopeOf(spec: {
@@ -48,6 +48,7 @@ export interface Prepared {
48
48
  tasks: number;
49
49
  costMicroUsd: number;
50
50
  };
51
+ rewindNotes?: NonNullable<TaskResult["rewindNotes"]>;
51
52
  cwdRef?: CwdRef;
52
53
  worktreeSessionRef?: {
53
54
  current?: {
@@ -307,6 +308,7 @@ export interface RunInternals {
307
308
  parentTaskId?: string;
308
309
  parentSessionId?: string;
309
310
  rootSessionId?: string;
311
+ registryScope?: string;
310
312
  parentCenterArtifactDigest?: string;
311
313
  parentCenterSourceRevision?: string;
312
314
  promptProfile?: "simple" | "classic";
@@ -409,7 +409,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
409
409
  }));
410
410
  }
411
411
  }
412
- const taskScope = spec.principal ?? "default";
412
+ const taskScope = internals?.registryScope ?? spec.principal ?? "default";
413
413
  defaultTaskRegistry.maybeGc();
414
414
  const forgetOnThrow = () => forgetQuietly(sessions, sessionId);
415
415
  const extraBodyCollisions = reservedCollisions(model.extraBody, reservedFor(model.api));
@@ -616,8 +616,42 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
616
616
  catch {
617
617
  }
618
618
  }
619
- let rewindTarget = spec.resumeAt !== undefined ? (spec.rewindFiles ? spec.resumeAt : undefined) : spec.rewindFilesTo;
619
+ const rewindNotes = [];
620
+ const rewindCaptureRequested = spec.rewindFiles === true;
621
+ if (spec.resumeAt !== undefined && spec.rewindFilesTo !== undefined) {
622
+ const e = new Error(`rewind-files: resumeAt ("${spec.resumeAt}") and rewindFilesTo ("${spec.rewindFilesTo}") were both set — resumeAt already anchors the file restore when rewindFiles is true, so a separate rewindFilesTo target is a conflicting request. Drop one of the two.`);
623
+ e.code = "rewind.conflicting_targets";
624
+ throw e;
625
+ }
626
+ let rewindTarget = spec.resumeAt !== undefined ? (rewindCaptureRequested ? spec.resumeAt : undefined) : spec.rewindFilesTo;
620
627
  const rewindBefore = rewindTarget !== undefined && spec.resumeAt !== undefined && spec.resumeAtMode === "before";
628
+ if (spec.resumeAt !== undefined && !rewindCaptureRequested) {
629
+ rewindNotes.push({
630
+ code: "conversation_only",
631
+ message: `the conversation was branched at entry "${spec.resumeAt}" but the working tree was NOT rewound — this task set resumeAt without rewindFiles, so files remain at their current state`,
632
+ });
633
+ }
634
+ if (!deps.fileSnapshotStore) {
635
+ if (rewindTarget !== undefined) {
636
+ const e = new Error(`rewind-files: restoring the working tree to entry "${rewindTarget}" requires a snapshot backend, but this deployment wired no RunnerDeps.fileSnapshotStore — no snapshot was ever captured, so the files were NOT rewound`);
637
+ e.code = "rewind.store_unconfigured";
638
+ throw e;
639
+ }
640
+ if (rewindCaptureRequested) {
641
+ rewindNotes.push({
642
+ code: "snapshot_store_unconfigured",
643
+ message: "rewindFiles was requested but no RunnerDeps.fileSnapshotStore is wired — no working-tree snapshot was captured for this turn, so it cannot be rewound to later",
644
+ });
645
+ }
646
+ }
647
+ else if (!handsEnabled) {
648
+ if (rewindTarget !== undefined || rewindCaptureRequested) {
649
+ rewindNotes.push({
650
+ code: "files_env_unsupported",
651
+ message: "the file side of rewind was inert: this deployment mounts no filesystem-capable ExecutionEnv, so no working-tree snapshot was captured or restored",
652
+ });
653
+ }
654
+ }
621
655
  if (rewindTarget !== undefined && deps.fileSnapshotStore && handsEnabled) {
622
656
  if (rewindBefore) {
623
657
  let anchor;
@@ -642,18 +676,13 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
642
676
  const restoreSignal = spec.signal ? AbortSignal.any([abortController.signal, spec.signal]) : abortController.signal;
643
677
  const restored = await deps.fileSnapshotStore.restore(sessionId, rewindTarget, executionEnv, restoreRoot, restoreSignal);
644
678
  if (!restored.ok) {
645
- if (restored.error.code === "not_found" && rewindBefore) {
646
- const e = new Error(`rewind-files: the resolved "before" snapshot anchor "${rewindTarget}" disappeared before restore — files were NOT rewound`);
679
+ if (restored.error.code === "not_found") {
680
+ const e = new Error(rewindBefore
681
+ ? `rewind-files: the resolved "before" snapshot anchor "${rewindTarget}" disappeared before restore — files were NOT rewound`
682
+ : `rewind-files: no file snapshot exists for entry "${rewindTarget}" on session "${sessionId}" — the working tree was NOT rewound (only a COMPLETED turn that ran with rewindFiles is snapshotted, and snapshots are keyed by that turn's END leaf; earlier turns that ran without rewindFiles, mid-turn entries, and reaped snapshots have none)`);
647
683
  e.code = "rewind_snapshot.unresolvable";
648
684
  throw e;
649
685
  }
650
- if (restored.error.code === "not_found") {
651
- try {
652
- deps.onError?.(new Error(`rewind-files: no snapshot for entry "${rewindTarget}" — files left unchanged`), { phase: "rewind", sessionId });
653
- }
654
- catch {
655
- }
656
- }
657
686
  else {
658
687
  const e = new Error(`rewind-files restore failed (${restored.error.code}): ${restored.error.message}`);
659
688
  e.code = "rewind.restore_failed";
@@ -1809,15 +1838,6 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1809
1838
  }
1810
1839
  const activeTools = new Set();
1811
1840
  const fpRef = {};
1812
- if (rebuildHarnessToolsRef.current === undefined) {
1813
- rebuildHarnessToolsRef.current = async () => {
1814
- const list = [...tools];
1815
- await harnessRef.current.setTools(list, list.map((t) => t.name));
1816
- if (fpRef.current)
1817
- fpRef.current.tools = toolsToFingerprintInputs(list);
1818
- turnSnapshotRef.current?.refreshTools(toolsToFingerprintInputs(list));
1819
- };
1820
- }
1821
1841
  const turnSnapshotRef = {};
1822
1842
  let harnessTools = tools;
1823
1843
  const failedMcpServers = mcp.statuses
@@ -1852,7 +1872,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1852
1872
  ...(executionMode !== undefined ? { executionMode } : {}),
1853
1873
  activate: async () => {
1854
1874
  if (activeTools.has(name))
1855
- return;
1875
+ return undefined;
1856
1876
  activeTools.add(name);
1857
1877
  try {
1858
1878
  await rematerialize(activeTools);
@@ -1861,6 +1881,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1861
1881
  activeTools.delete(name);
1862
1882
  throw e;
1863
1883
  }
1884
+ return listingRideRef.current?.([name]);
1864
1885
  },
1865
1886
  };
1866
1887
  };
@@ -1883,7 +1904,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1883
1904
  if (deferred.has(n))
1884
1905
  activeTools.add(n);
1885
1906
  }
1886
- offloadReachableToolsRef.current = () => {
1907
+ const callableToolNames = () => {
1887
1908
  const s = new Set(tools.map((t) => t.name));
1888
1909
  for (const n of deferred)
1889
1910
  if (!activeTools.has(n))
@@ -1891,6 +1912,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1891
1912
  s.add(TOOL_SEARCH_NAME);
1892
1913
  return s;
1893
1914
  };
1915
+ offloadReachableToolsRef.current = callableToolNames;
1894
1916
  let toolSearch;
1895
1917
  const buildToolList = (active) => {
1896
1918
  const list = tools.map((t) => (deferred.has(t.name) && !active.has(t.name) ? placeholders.get(t.name) : t));
@@ -1932,11 +1954,20 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1932
1954
  active: activeTools,
1933
1955
  rematerialize,
1934
1956
  listingRide: (newly) => listingRideRef.current?.(newly),
1935
- mountedNames: () => new Set(buildToolList(activeTools).map((t) => t.name).filter((n) => !registry.has(n) || activeTools.has(n))),
1957
+ mountedNames: callableToolNames,
1936
1958
  directCallEnabled: spec.deferSelfResolve !== false,
1937
1959
  });
1938
1960
  harnessTools = buildToolList(activeTools);
1939
1961
  }
1962
+ else {
1963
+ rebuildHarnessToolsRef.current = async () => {
1964
+ const list = [...tools];
1965
+ await harnessRef.current.setTools(list, list.map((t) => t.name));
1966
+ if (fpRef.current)
1967
+ fpRef.current.tools = toolsToFingerprintInputs(list);
1968
+ turnSnapshotRef.current?.refreshTools(toolsToFingerprintInputs(list));
1969
+ };
1970
+ }
1940
1971
  if (spec.agents !== undefined && spec.agents.length > 0) {
1941
1972
  const known = new Set(tools.flatMap((t) => [canonicalToolName(t.name), ...(t.aliases ?? []).map((a) => canonicalToolName(a))]));
1942
1973
  for (const def of spec.agents) {
@@ -3357,7 +3388,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3357
3388
  : undefined;
3358
3389
  overheadState.promptChars = systemPrompt.length;
3359
3390
  const preparedHolder = {};
3360
- const buildPrepared = () => ({ harness, session, sessionId, taskRootPath, model, thinking, compModel, mcp: mcp, blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, nestedStats, cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), releaseSignal, cacheBreakDetector, cacheFingerprint, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), ownedEnv, suspendRef, reviewRef, reviewRequestRef, suspendLoopRef, suspendForResource, ...(callCapRef ? { callCapRef } : {}), ...(cutKills ? { cutKills } : {}), ...(callCapRef ? { callIssuedAtRef } : {}), suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, promptOverheadTokens, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, listBackgroundTasks, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
3391
+ const buildPrepared = () => ({ harness, session, sessionId, taskRootPath, model, thinking, compModel, mcp: mcp, blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), releaseSignal, cacheBreakDetector, cacheFingerprint, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), ownedEnv, suspendRef, reviewRef, reviewRequestRef, suspendLoopRef, suspendForResource, ...(callCapRef ? { callCapRef } : {}), ...(cutKills ? { cutKills } : {}), ...(callCapRef ? { callIssuedAtRef } : {}), suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, promptOverheadTokens, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, listBackgroundTasks, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
3361
3392
  const prepared = buildPrepared();
3362
3393
  preparedHolder.current = prepared;
3363
3394
  return prepared;
@@ -2764,6 +2764,7 @@ export class Runner {
2764
2764
  threw,
2765
2765
  model: prepared.model.id,
2766
2766
  unpricedSpend: rs.telemetry.unpricedSpend,
2767
+ rewindNotes: prepared.rewindNotes,
2767
2768
  abortedForTimeout: timeout.fired,
2768
2769
  abortedForTurns: rs.limits.turnsExceeded,
2769
2770
  abortedLive,
@@ -3045,10 +3046,19 @@ export class Runner {
3045
3046
  if (!leafId)
3046
3047
  return;
3047
3048
  const root = prepared.taskRootPath;
3048
- const refusedAt = this.snapshotTooLargeRoots.get(root);
3049
- if (refusedAt !== undefined) {
3050
- if (Date.now() - refusedAt < SNAPSHOT_TOO_LARGE_TTL_MS)
3049
+ const refusal = this.snapshotTooLargeRoots.get(root);
3050
+ if (refusal !== undefined) {
3051
+ if (Date.now() - refusal.refusedAt < SNAPSHOT_TOO_LARGE_TTL_MS) {
3052
+ if (!refusal.skipAnnounced) {
3053
+ refusal.skipAnnounced = true;
3054
+ try {
3055
+ this.deps.onError?.(new Error(`rewind-files snapshot skipped for this working-tree root: a too_large refusal is still inside its ${SNAPSHOT_TOO_LARGE_TTL_MS / 60_000}-minute cooldown (re-probed at ${new Date(refusal.refusedAt + SNAPSHOT_TOO_LARGE_TTL_MS).toISOString()}) — turns completed during the cooldown capture no snapshot and cannot be rewound to. Announced once per cooldown window.`), { phase: "rewind", sessionId: prepared.sessionId, classification: "too_large" });
3056
+ }
3057
+ catch {
3058
+ }
3059
+ }
3051
3060
  return;
3061
+ }
3052
3062
  this.snapshotTooLargeRoots.delete(root);
3053
3063
  }
3054
3064
  const ac = new AbortController();
@@ -3071,11 +3081,14 @@ export class Runner {
3071
3081
  }
3072
3082
  if (!r.ok) {
3073
3083
  const tooLarge = r.error.code === "too_large";
3084
+ const refusedAt = Date.now();
3074
3085
  if (tooLarge)
3075
- this.snapshotTooLargeRoots.set(root, Date.now());
3086
+ this.snapshotTooLargeRoots.set(root, { refusedAt, skipAnnounced: false });
3076
3087
  try {
3077
3088
  this.deps.onError?.(new Error(`rewind-files snapshot failed (${r.error.code}): ${r.error.message}` +
3078
- (tooLarge ? " — skipping further snapshots for this root (this process); raise the store's snapshotBounds or shrink the tree to re-enable rewind" : "")), { phase: "rewind", sessionId: prepared.sessionId, ...(tooLarge ? { classification: "too_large" } : {}) });
3089
+ (tooLarge
3090
+ ? ` — skipping further snapshots for this root (this process) during a ${SNAPSHOT_TOO_LARGE_TTL_MS / 60_000}-minute cooldown, until ${new Date(refusedAt + SNAPSHOT_TOO_LARGE_TTL_MS).toISOString()}, when the tree is re-probed automatically. Raise the store's snapshotBounds to accept a tree this size; shrinking the tree also re-enables rewind, but only at that re-probe — not immediately`
3091
+ : "")), { phase: "rewind", sessionId: prepared.sessionId, ...(tooLarge ? { classification: "too_large" } : {}) });
3079
3092
  }
3080
3093
  catch {
3081
3094
  }
@@ -31,7 +31,7 @@ export declare function buildDeferredRegistry(deferred: ReadonlySet<string>, too
31
31
  export interface PlaceholderDirectCall {
32
32
  resolveReal: () => PlaceholderDirectTarget | undefined;
33
33
  executionMode?: ToolExecutionMode;
34
- activate: () => Promise<void>;
34
+ activate: () => Promise<string | undefined>;
35
35
  }
36
36
  export interface PlaceholderDirectTarget {
37
37
  parameters: TSchema;
@@ -79,8 +79,11 @@ export function createPlaceholderTool(info, direct) {
79
79
  execute: async (toolCallId, params, signal, onUpdate) => {
80
80
  const real = direct.resolveReal();
81
81
  if (real !== undefined && Value.Check(real.parameters, params)) {
82
- await direct.activate();
83
- return real.invoke(toolCallId, params, signal, onUpdate);
82
+ const ride = await direct.activate();
83
+ const result = await real.invoke(toolCallId, params, signal, onUpdate);
84
+ if (ride === undefined || ride === "")
85
+ return result;
86
+ return { ...result, content: [...result.content, { type: "text", text: ride }] };
84
87
  }
85
88
  return teachingRejection();
86
89
  },
@@ -168,15 +171,27 @@ export function resolveToolSearch(args, registry) {
168
171
  }
169
172
  export function extractDiscoveredToolNames(messages, registry) {
170
173
  const names = new Set();
174
+ const pendingDirect = new Map();
171
175
  for (const m of messages) {
176
+ if (m.role === "toolResult") {
177
+ const name = pendingDirect.get(m.toolCallId);
178
+ if (name !== undefined && m.isError !== true)
179
+ names.add(name);
180
+ continue;
181
+ }
172
182
  if (m.role !== "assistant")
173
183
  continue;
174
184
  for (const part of m.content) {
175
- if (part.type === "toolCall" && part.name === TOOL_SEARCH_NAME) {
185
+ if (part.type !== "toolCall")
186
+ continue;
187
+ if (part.name === TOOL_SEARCH_NAME) {
176
188
  for (const n of resolveToolSearch(part.arguments, registry)) {
177
189
  names.add(n);
178
190
  }
179
191
  }
192
+ else if (registry.has(part.name)) {
193
+ pendingDirect.set(part.id, part.name);
194
+ }
180
195
  }
181
196
  }
182
197
  return [...names];
@@ -12,6 +12,7 @@ export interface TtlSessionStoreOptions {
12
12
  export declare class TtlSessionStore implements SessionStore {
13
13
  private repo;
14
14
  private entries;
15
+ private owners;
15
16
  private pending;
16
17
  private pinned;
17
18
  private defaultTtlMs;
@@ -31,6 +32,8 @@ export declare class TtlSessionStore implements SessionStore {
31
32
  forget(sessionId: string): void;
32
33
  pin(sessionId: string): void;
33
34
  unpin(sessionId: string): void;
35
+ ownerOf(sessionId: string): Promise<string | null | undefined>;
36
+ register(sessionId: string, owner: string | null): Promise<void>;
34
37
  sweep(now?: number): void;
35
38
  get size(): number;
36
39
  dispose(): void;
@@ -6,6 +6,7 @@ export { SESSION_DEFAULT_TTL_DAYS };
6
6
  export class TtlSessionStore {
7
7
  repo;
8
8
  entries = new Map();
9
+ owners = new Map();
9
10
  pending = new Map();
10
11
  pinned = new Set();
11
12
  defaultTtlMs;
@@ -132,6 +133,7 @@ export class TtlSessionStore {
132
133
  this.entries.delete(sessionId);
133
134
  if (this.evictPolicy === "delete") {
134
135
  await this.repo.delete(await e.session.getMetadata());
136
+ this.owners.delete(sessionId);
135
137
  }
136
138
  }
137
139
  forget(sessionId) {
@@ -143,6 +145,13 @@ export class TtlSessionStore {
143
145
  unpin(sessionId) {
144
146
  this.pinned.delete(sessionId);
145
147
  }
148
+ async ownerOf(sessionId) {
149
+ return this.owners.has(sessionId) ? this.owners.get(sessionId) : undefined;
150
+ }
151
+ async register(sessionId, owner) {
152
+ if (!this.owners.has(sessionId))
153
+ this.owners.set(sessionId, owner);
154
+ }
146
155
  sweep(now = Date.now()) {
147
156
  for (const [id, e] of this.entries) {
148
157
  if (this.pinned.has(id)) {
@@ -152,6 +161,7 @@ export class TtlSessionStore {
152
161
  this.entries.delete(id);
153
162
  if (this.evictPolicy === "delete") {
154
163
  void this.repo.delete({ id, createdAt: "" });
164
+ this.owners.delete(id);
155
165
  }
156
166
  }
157
167
  }
@@ -31,6 +31,8 @@ export interface SessionStore {
31
31
  noteTaskRun?(sessionId: string, taskId: string): void | Promise<void>;
32
32
  list?(): Promise<SessionStoreSummary[]>;
33
33
  fork?(sourceId: string, owner?: string | null): Promise<string | null>;
34
+ ownerOf?(sessionId: string): Promise<string | null | undefined>;
35
+ register?(sessionId: string, owner: string | null): Promise<void>;
34
36
  readonly size: number;
35
37
  dispose(): void | Promise<void>;
36
38
  }
@@ -652,6 +652,9 @@ export function settleBackgroundAgentLane(core, id, outcome) {
652
652
  const handle = core.handles.get(id);
653
653
  if (!handle || handle.type !== "background_agent")
654
654
  return undefined;
655
+ if (outcome.cycle === undefined && (handle.reviveCycle ?? 0) !== 0) {
656
+ throw new Error(`settleBackgroundAgent("${id}"): the row has been revived (cycle ${handle.reviveCycle}) — pass the cycle this settle speaks for, or use settleRevivedAgent`);
657
+ }
655
658
  if ((outcome.cycle ?? 0) !== (handle.reviveCycle ?? 0))
656
659
  return undefined;
657
660
  if (handle.status !== "running") {
@@ -21,6 +21,8 @@ function isOffloadedPreview(content) {
21
21
  return first?.type === "text" && typeof first.text === "string" && first.text.startsWith(PERSISTED_OUTPUT_PREFIX);
22
22
  }
23
23
  function replaceTextBlock(m, text) {
24
+ if (!isToolResult(m))
25
+ return m;
24
26
  const images = m.content.filter((b) => b.type !== "text");
25
27
  return { ...m, content: [{ type: "text", text }, ...images] };
26
28
  }
@@ -390,6 +390,10 @@ export interface TaskResult {
390
390
  atTurn: number;
391
391
  };
392
392
  structuredOutput?: unknown;
393
+ rewindNotes?: Array<{
394
+ code: "conversation_only" | "files_env_unsupported" | "snapshot_store_unconfigured";
395
+ message: string;
396
+ }>;
393
397
  stats: {
394
398
  turns: number;
395
399
  tokens: number;
@@ -34,8 +34,9 @@ export function buildSessionContext(pathEntries, opts) {
34
34
  }
35
35
  }
36
36
  const messages = [];
37
+ const isAssistantMessage = (m) => m.role === "assistant";
37
38
  const stripAssistantUsage = (message) => {
38
- if (message.role !== "assistant") {
39
+ if (!isAssistantMessage(message)) {
39
40
  return message;
40
41
  }
41
42
  return {
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export { createSkillsFromDirectory, type SkillsDirectoryOptions, type SkillsDire
7
7
  export { REPORT_FINDINGS_TOOL_NAME, type ReportedFinding } from "./core/runner/synthetic-tools.js";
8
8
  export { formatToolError, formatZodValidationError, formatValidationPath, truncateError, errorClassOf } from "./core/tool-errors.js";
9
9
  export type { WorkerErrorClass } from "./core/tool-errors.js";
10
- export { createWebFetchTool, webFetchToolSpec, htmlToText, type WebFetchConfig, createWebSearchTool, type WebSearchConfig, createWebFetchSummarizer, WEBFETCH_SUMMARY_MAX_CONTENT, WEBFETCH_SUMMARY_GUIDELINES, } from "./tools/web.js";
10
+ export { createWebFetchTool, webFetchToolSpec, htmlToText, type WebFetchConfig, createWebSearchTool, type WebSearchConfig, createSearxngSearchBackend, probeSearchBackend, type SearxngBackendOptions, createWebFetchSummarizer, WEBFETCH_SUMMARY_MAX_CONTENT, WEBFETCH_SUMMARY_GUIDELINES, } from "./tools/web.js";
11
11
  export { createTodoWriteTool } from "./tools/todo.js";
12
12
  export { createTaskListTools, createMemoryTaskListStore, assertJsonMetadata, type TaskListItem, type TaskListStore } from "./tools/task-list.js";
13
13
  export { assembleCodeTools, type CodeToolsConfig, CODE_ROLE } from "./scenarios/full-body.js";
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ export { SKILL_TOOL_NAME } from "./core/runner/synthetic-tools.js";
5
5
  export { createSkillsFromDirectory, } from "./core/skills-directory.js";
6
6
  export { REPORT_FINDINGS_TOOL_NAME } from "./core/runner/synthetic-tools.js";
7
7
  export { formatToolError, formatZodValidationError, formatValidationPath, truncateError, errorClassOf } from "./core/tool-errors.js";
8
- export { createWebFetchTool, webFetchToolSpec, htmlToText, createWebSearchTool, createWebFetchSummarizer, WEBFETCH_SUMMARY_MAX_CONTENT, WEBFETCH_SUMMARY_GUIDELINES, } from "./tools/web.js";
8
+ export { createWebFetchTool, webFetchToolSpec, htmlToText, createWebSearchTool, createSearxngSearchBackend, probeSearchBackend, createWebFetchSummarizer, WEBFETCH_SUMMARY_MAX_CONTENT, WEBFETCH_SUMMARY_GUIDELINES, } from "./tools/web.js";
9
9
  export { createTodoWriteTool } from "./tools/todo.js";
10
10
  export { createTaskListTools, createMemoryTaskListStore, assertJsonMetadata } from "./tools/task-list.js";
11
11
  export { assembleCodeTools, CODE_ROLE } from "./scenarios/full-body.js";
@@ -9,6 +9,7 @@ import { combinePolicies, createAllowDenyPolicy } from "../core/tool-policy.js";
9
9
  import { callKeyOrdinal, oversizeJournalResult, journalOversizeTombstone, JOURNAL_OVERSIZE_ERROR_CODE, MAX_JOURNAL_RESULT_BYTES } from "../core/workflow-journal-store.js";
10
10
  import { isWorkflowRunActive, closeWorkflowChannel, markWorkflowActive, publishWorkflowEvent } from "./workflow-observe.js";
11
11
  import { isDurablePause, mapNestedSuspend } from "../agents/suspend-guard.js";
12
+ import { RUNNING_AGENT_OBSERVE_EVERY_BEATS } from "../config/defaults.js";
12
13
  import { boundInputHashOf } from "../core/canonical-json.js";
13
14
  import { boundedRedactedSummary } from "../core/untrusted-egress.js";
14
15
  import { delimitUntrusted } from "../core/untrusted-text.js";
@@ -20,7 +21,6 @@ const MAX_TRANSCRIPT_CHARS = 4000;
20
21
  const WORKFLOW_RESULT_MAX = 4000;
21
22
  const WORKFLOW_RESULT_FULL_MAX = 200_000;
22
23
  const MAX_ACTIVITY = 30;
23
- const RUNNING_AGENT_PERSIST_EVERY_BEATS = 4;
24
24
  function workflowModelLabel(spec) {
25
25
  const model = spec.model;
26
26
  if (model === undefined)
@@ -453,16 +453,17 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
453
453
  };
454
454
  const waIdOf = (callKey) => `wa${createHash("sha256").update(`${runId}:${callKey}`).digest("hex").slice(0, 16)}`;
455
455
  const bceLive = new Map();
456
- const bceSpawn = (callKey, label, agentType, replayed) => {
456
+ const bceSpawn = (callKey, label, agentType, replayed, sessionId) => {
457
457
  if (!bceSink)
458
458
  return;
459
459
  const id = waIdOf(callKey);
460
- bceLive.set(id, { callKey, label, ...(agentType !== undefined ? { agentType } : {}) });
460
+ bceLive.set(id, { callKey, label, ...(agentType !== undefined ? { agentType } : {}), ...(sessionId !== undefined ? { sessionId } : {}) });
461
461
  bceEmit({
462
462
  kind: "spawn",
463
463
  taskId: id,
464
464
  sessionScoped: false,
465
465
  owner: runId,
466
+ ...(sessionId !== undefined ? { sessionId, transcriptId: sessionId } : {}),
466
467
  ...(scope !== undefined ? { scope } : {}),
467
468
  description: replayed ? `${label} (replayed)` : label,
468
469
  agentType: agentType ?? "workflow-agent",
@@ -474,6 +475,11 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
474
475
  startedAt: Date.now(),
475
476
  });
476
477
  };
478
+ const bceBindSession = (callKey, sessionId) => {
479
+ const row = bceLive.get(waIdOf(callKey));
480
+ if (row !== undefined)
481
+ row.sessionId = sessionId;
482
+ };
477
483
  const bceTick = (callKey, e) => {
478
484
  if (!bceSink)
479
485
  return;
@@ -489,6 +495,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
489
495
  workflowRunId: runId,
490
496
  ...(scope !== undefined ? { scope } : {}),
491
497
  ...(row.agentType !== undefined ? { agentType: row.agentType } : { agentType: "workflow-agent" }),
498
+ ...(row.sessionId !== undefined ? { sessionId: row.sessionId, transcriptId: row.sessionId } : {}),
492
499
  name: e.name ?? row.label,
493
500
  progressTaskId: e.taskId,
494
501
  ...(e.parentTaskId !== undefined ? { progressParentTaskId: e.parentTaskId } : {}),
@@ -635,7 +642,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
635
642
  rec.toolCalls = (rec.toolCalls ?? 0) + 1;
636
643
  rec.activity = tail;
637
644
  beatCount += 1;
638
- if (beatCount === 1 || beatCount % RUNNING_AGENT_PERSIST_EVERY_BEATS === 0) {
645
+ if (beatCount === 1 || beatCount % RUNNING_AGENT_OBSERVE_EVERY_BEATS === 0) {
639
646
  void persist("update");
640
647
  }
641
648
  };
@@ -768,7 +775,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
768
775
  agentPhaseOf.set(replayRec, phaseInstance);
769
776
  emit({ type: "agent_start", runId, label, phase, ...(groupId !== undefined ? { groupId } : {}), callKey, prompt, ...(model !== undefined ? { model } : {}), replayed: true, ts: at });
770
777
  emit({ type: "agent_end", runId, label, phase, ...(groupId !== undefined ? { groupId } : {}), status: replayRec.status, output: cachedOutput, ...(rs.toolCalls !== undefined ? { toolCalls: rs.toolCalls } : {}), replayed: true, ts: at });
771
- bceSpawn(callKey, label, agentOpts.agentType, true);
778
+ bceSpawn(callKey, label, agentOpts.agentType, true, r.sessionId || undefined);
772
779
  bceTerminal(callKey, replayRec.status === "completed" ? "completed" : "failed", cachedOutput, r.sessionId || undefined, replayRec.stats);
773
780
  accumulateStats(r, false);
774
781
  void persist("update");
@@ -810,7 +817,8 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
810
817
  if (finalized)
811
818
  throw new Error("workflow run already finalized — ctx.agent cannot spawn after the run ended");
812
819
  rec.startedAt = now();
813
- bceSpawn(callKey, label, agentOpts.agentType, false);
820
+ const bornChildSessionId = resolveChildSessionIdAtSpawn(spec);
821
+ bceSpawn(callKey, label, agentOpts.agentType, false, bornChildSessionId);
814
822
  const typedSpec0 = applyWorkflowAgentType(spec, agentOpts.agentType, agentRegistry);
815
823
  const typedSpec = typedSpec0.model === undefined && inheritedModelSnap !== undefined ? { ...typedSpec0, model: inheritedModelSnap } : typedSpec0;
816
824
  const framedSpec = withWorkflowChildPersona(typedSpec, agentOpts.schema ?? typedSpec.outputSchema);
@@ -852,10 +860,11 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
852
860
  const onCallerAbort = () => attemptCtl.abort(new Error("workflow aborted"));
853
861
  effectiveSignal?.addEventListener("abort", onCallerAbort, { once: true });
854
862
  armWatchdog();
855
- const attemptSessionId = resolveChildSessionIdAtSpawn(runSpec);
863
+ const attemptSessionId = attempts === 1 ? bornChildSessionId : resolveChildSessionIdAtSpawn(runSpec);
856
864
  const attemptSpec = attemptSessionId !== undefined ? { ...runSpec, sessionId: attemptSessionId, signal: attemptCtl.signal } : { ...runSpec, signal: attemptCtl.signal };
857
865
  if (attemptSessionId !== undefined && !finalized) {
858
866
  rec.sessionId = attemptSessionId;
867
+ bceBindSession(callKey, attemptSessionId);
859
868
  void persist("update");
860
869
  }
861
870
  const attemptInternals = {
@@ -1143,9 +1152,9 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1143
1152
  throw new Error(finalized ? "workflow run already finalized — ctx.agentStream cannot spawn after the run ended" : "workflow aborted");
1144
1153
  }
1145
1154
  rec.startedAt = now();
1146
- bceSpawn(callKey, label, agentOpts.agentType, false);
1155
+ const childSessionId = resolveChildSessionIdAtSpawn(spec);
1156
+ bceSpawn(callKey, label, agentOpts.agentType, false, childSessionId);
1147
1157
  let stream;
1148
- let childSessionId;
1149
1158
  try {
1150
1159
  const typedSpec0 = applyWorkflowAgentType(spec, agentOpts.agentType, agentRegistry);
1151
1160
  const typedSpec = typedSpec0.model === undefined && inheritedModelSnap !== undefined ? { ...typedSpec0, model: inheritedModelSnap } : typedSpec0;
@@ -1154,7 +1163,6 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1154
1163
  const baseRunSpec = agentOpts.schema
1155
1164
  ? { ...framedSpec, ...authInherit, signal: effectiveSignal, outputSchema: agentOpts.schema }
1156
1165
  : { ...framedSpec, ...authInherit, signal: effectiveSignal };
1157
- childSessionId = resolveChildSessionIdAtSpawn(baseRunSpec);
1158
1166
  const runSpec = childSessionId !== undefined ? { ...baseRunSpec, sessionId: childSessionId } : baseRunSpec;
1159
1167
  const enrichedForwardS = opts.onForwardEvent !== undefined
1160
1168
  ? (e) => {
@@ -152,6 +152,7 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
152
152
  return errorResult(`Error (Edit): "${path}" has a truncated UTF-16 body; repair/convert it with bash first.`);
153
153
  const original = decoded.text;
154
154
  const entry = state.get(r.key);
155
+ const readWasTruncated = entry?.truncated === true;
155
156
  const stale = checkStale(entry, sha256(original));
156
157
  if (stale)
157
158
  return errorResult(violationText("Edit", stale));
@@ -180,7 +181,7 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
180
181
  oldS = resolvedEsc;
181
182
  }
182
183
  }
183
- const match = checkEditMatch(working, oldS, e.replace_all === true, entry.truncated);
184
+ const match = checkEditMatch(working, oldS, e.replace_all === true, readWasTruncated);
184
185
  if (match) {
185
186
  const escNote = match.code === "ambiguous_edit" && escapeMatchWasAttempted(e.old_string) && !working.includes(oldS) ? ESCAPE_MATCH_MISS_NOTE : "";
186
187
  return errorResult(batch ? `Error (Edit): ${where}${match.message}${escNote} (no changes written — the batch is atomic).` : `${violationText("Edit", match)}${escNote}`);
@@ -58,7 +58,7 @@ export declare const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read to
58
58
  export declare const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view \u2014 the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
59
59
  export declare const WRITE_ENCODING_DEADLOCK_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead \u2014 e.g. `rm` + rewrite, or `iconv`.)";
60
60
  export declare function checkNoChange(oldString: string, newString: string): FsViolation | undefined;
61
- export declare function checkStale(entry: ReadEntry, currentHash: string): FsViolation | undefined;
61
+ export declare function checkStale(entry: ReadEntry | undefined, currentHash: string): FsViolation | undefined;
62
62
  export declare function countOccurrences(haystack: string, needle: string): number;
63
63
  export declare function similarNameSuggestion(siblingNames: readonly string[], missingName: string): string | undefined;
64
64
  export declare function normalizeQuotes(s: string): string;
@@ -276,7 +276,7 @@ export function checkNoChange(oldString, newString) {
276
276
  return undefined;
277
277
  }
278
278
  export function checkStale(entry, currentHash) {
279
- if (entry.hash !== currentHash) {
279
+ if (entry === undefined || entry.hash !== currentHash) {
280
280
  return { code: "stale", message: "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it." };
281
281
  }
282
282
  return undefined;
@@ -29,3 +29,18 @@ export interface WebSearchConfig {
29
29
  }
30
30
  export declare function clipCodePoints(s: string, max: number): string;
31
31
  export declare function createWebSearchTool(config: WebSearchConfig): ToolSpec;
32
+ export interface SearxngBackendOptions {
33
+ fetchImpl?: typeof fetch;
34
+ timeoutMs?: number;
35
+ extraParams?: Record<string, string>;
36
+ }
37
+ export declare function createSearxngSearchBackend(baseUrl: string, options?: SearxngBackendOptions): WebSearchConfig["search"];
38
+ export declare function probeSearchBackend(search: WebSearchConfig["search"], options?: {
39
+ timeoutMs?: number;
40
+ }): Promise<{
41
+ ok: true;
42
+ results: number;
43
+ } | {
44
+ ok: false;
45
+ error: string;
46
+ }>;
package/dist/tools/web.js CHANGED
@@ -798,3 +798,45 @@ export function createWebSearchTool(config) {
798
798
  },
799
799
  };
800
800
  }
801
+ export function createSearxngSearchBackend(baseUrl, options = {}) {
802
+ const doFetch = options.fetchImpl ?? globalThis.fetch;
803
+ const timeoutMs = options.timeoutMs ?? 10_000;
804
+ const base = baseUrl.replace(/\/+$/, "");
805
+ return async (query, signal, opts) => {
806
+ const q = opts?.allowedDomains && opts.allowedDomains.length > 0
807
+ ? `${opts.allowedDomains.map((d) => `site:${d}`).join(" OR ")} ${query}`
808
+ : query;
809
+ const url = new URL(`${base}/search`);
810
+ url.searchParams.set("q", q);
811
+ url.searchParams.set("format", "json");
812
+ for (const [k, v] of Object.entries(options.extraParams ?? {}))
813
+ url.searchParams.set(k, v);
814
+ const timeout = AbortSignal.timeout(timeoutMs);
815
+ const res = await doFetch(url, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout, headers: { accept: "application/json" } });
816
+ if (!res.ok)
817
+ throw new Error(`SearXNG ${res.status} ${res.statusText} from ${base}/search`);
818
+ const body = (await res.json());
819
+ if (!Array.isArray(body.results))
820
+ throw new Error(`SearXNG returned no results array from ${base}/search — is format=json enabled on this instance?`);
821
+ return body.results
822
+ .filter((r) => typeof r.url === "string" && r.url !== "")
823
+ .map((r) => ({
824
+ title: typeof r.title === "string" && r.title !== "" ? r.title : r.url,
825
+ url: r.url,
826
+ snippet: typeof r.content === "string" ? r.content : "",
827
+ }));
828
+ };
829
+ }
830
+ export async function probeSearchBackend(search, options) {
831
+ const budget = options?.timeoutMs ?? 15_000;
832
+ try {
833
+ const results = await Promise.race([
834
+ search("connectivity probe", AbortSignal.timeout(budget)),
835
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`probe timed out after ${budget}ms`)), budget)),
836
+ ]);
837
+ return { ok: true, results: results.length };
838
+ }
839
+ catch (e) {
840
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
841
+ }
842
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "2.9.0",
3
+ "version": "2.11.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",