@sema-agent/core 2.0.1 → 2.2.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 (71) hide show
  1. package/dist/agents/observer.d.ts +14 -0
  2. package/dist/agents/observer.js +66 -12
  3. package/dist/agents/send-message-tool.js +164 -88
  4. package/dist/agents/subagent.d.ts +11 -4
  5. package/dist/agents/subagent.js +166 -62
  6. package/dist/core/context-edit.js +16 -3
  7. package/dist/core/file-snapshot-store.js +10 -1
  8. package/dist/core/memory-engine/dual-root.js +2 -0
  9. package/dist/core/memory-engine/engine.d.ts +4 -0
  10. package/dist/core/memory-engine/engine.js +6 -1
  11. package/dist/core/runner/prepare-memory.d.ts +4 -0
  12. package/dist/core/runner/prepare-memory.js +4 -1
  13. package/dist/core/runner/prepare-task.d.ts +9 -2
  14. package/dist/core/runner/prepare-task.js +68 -13
  15. package/dist/core/runner/runtask.js +919 -865
  16. package/dist/core/runner/synthetic-tools.d.ts +1 -0
  17. package/dist/core/runner/synthetic-tools.js +18 -15
  18. package/dist/core/runner/turn-attachments.d.ts +27 -3
  19. package/dist/core/runner/turn-attachments.js +101 -12
  20. package/dist/core/task-registry-agent.d.ts +9 -1
  21. package/dist/core/task-registry-agent.js +23 -2
  22. package/dist/core/task-registry-monitor.js +79 -24
  23. package/dist/core/task-registry-shared.d.ts +13 -1
  24. package/dist/core/task-registry-shared.js +21 -0
  25. package/dist/core/task-registry.d.ts +2 -0
  26. package/dist/core/task-registry.js +24 -26
  27. package/dist/core/tool-result-budget.js +2 -2
  28. package/dist/core/tool-result-store.d.ts +2 -0
  29. package/dist/core/tool-result-store.js +27 -2
  30. package/dist/core/types.d.ts +4 -2
  31. package/dist/core/workflow-journal-store.d.ts +13 -0
  32. package/dist/engine/session/import-validate.js +29 -0
  33. package/dist/engine/session/memory-repo.js +5 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/orchestration/workflow-size-guideline.d.ts +6 -1
  37. package/dist/orchestration/workflow-size-guideline.js +19 -9
  38. package/dist/orchestration/workflow.d.ts +1 -0
  39. package/dist/orchestration/workflow.js +44 -1
  40. package/dist/prompt-assembly/assemble.js +3 -7
  41. package/dist/prompt-assembly/event-registry.js +1 -1
  42. package/dist/prompt-assembly/packs/sema-default.js +8 -5
  43. package/dist/prompts/coordinator.d.ts +1 -1
  44. package/dist/prompts/coordinator.js +45 -0
  45. package/dist/prompts/default.d.ts +4 -5
  46. package/dist/prompts/default.js +16 -18
  47. package/dist/prompts/simple-sections.d.ts +3 -1
  48. package/dist/prompts/simple-sections.js +11 -1
  49. package/dist/stores/cc/task-list-store.js +3 -3
  50. package/dist/stores/file/memory-store.d.ts +3 -0
  51. package/dist/stores/file/memory-store.js +39 -12
  52. package/dist/stores/file/tool-result-store.js +16 -2
  53. package/dist/stores/file/workflow-journal-store.d.ts +23 -0
  54. package/dist/stores/file/workflow-journal-store.js +140 -3
  55. package/dist/tools/fs/bash-readonly-classifier.js +21 -2
  56. package/dist/tools/fs/fs-bash.d.ts +1 -0
  57. package/dist/tools/fs/fs-bash.js +10 -3
  58. package/dist/tools/fs/fs-read.js +12 -12
  59. package/dist/tools/fs/fs-search-tools.js +42 -7
  60. package/dist/tools/fs/fs-write.js +18 -6
  61. package/dist/tools/fs/index.d.ts +1 -0
  62. package/dist/tools/fs/index.js +2 -1
  63. package/dist/tools/fs/safety.d.ts +4 -0
  64. package/dist/tools/fs/safety.js +113 -10
  65. package/dist/tools/fs/search.d.ts +1 -0
  66. package/dist/tools/fs/search.js +23 -3
  67. package/dist/tools/monitor.js +1 -1
  68. package/dist/tools/task-list.d.ts +1 -0
  69. package/dist/tools/task-list.js +13 -2
  70. package/dist/tools/web.js +36 -6
  71. package/package.json +3 -2
@@ -68,6 +68,15 @@ export declare class ObserverDigestTap {
68
68
  finish(reason: string): void;
69
69
  }
70
70
  export type ObserverPairingState = "armed" | "denied" | "stopped" | "retired" | "blocked";
71
+ export declare const OBSERVER_STOPPED_BY_USER_ERROR_NAME = "AgentStoppedByUserError";
72
+ export declare const OBSERVER_RESUME_STATE_ERROR_NAME = "ResumeAgentStateError";
73
+ export declare class ObserverStoppedByUserError extends Error {
74
+ constructor(message: string);
75
+ }
76
+ export declare class ObserverResumeStateError extends Error {
77
+ constructor(message: string);
78
+ }
79
+ export declare const OBSERVER_FRESH_START_NOTE = "[Note: your previous observation context was lost; this is a fresh start mid-task.]";
71
80
  export interface ObserverSpawner {
72
81
  spawnFirstRun(args: {
73
82
  framingPrompt: string;
@@ -76,6 +85,10 @@ export interface ObserverSpawner {
76
85
  deliver(args: {
77
86
  digest: string;
78
87
  }): Promise<void>;
88
+ restartFresh?(args: {
89
+ framingPrompt: string;
90
+ digest: string;
91
+ }): Promise<void>;
79
92
  }
80
93
  export declare class ObserverPairing {
81
94
  state: ObserverPairingState;
@@ -102,6 +115,7 @@ export declare class ObserverPairing {
102
115
  private safeOnError;
103
116
  drain(): Promise<void>;
104
117
  private pump;
118
+ private deliverBatch;
105
119
  retire(state: Exclude<ObserverPairingState, "armed">): void;
106
120
  }
107
121
  export declare function markObserverTaskId(taskId: string): void;
@@ -157,10 +157,15 @@ export class ObserverDigestTap {
157
157
  this.flushText();
158
158
  this.pending.push({ type: "tool_result", content: truncateDigestPayload(digestTextOf(e.output)) });
159
159
  return;
160
- case "steering_injected":
160
+ case "steering_injected": {
161
161
  this.flushText();
162
- this.pending.push({ type: "user_message", text: e.message ?? "(steering)" });
162
+ const injected = e.preview.trim();
163
+ this.pending.push({
164
+ type: "user_message",
165
+ text: injected ? `[injected: ${e.source}]\n${injected}` : `[injected: ${e.source}] (empty preview)`,
166
+ });
163
167
  return;
168
+ }
164
169
  case "turn_end":
165
170
  this.flushSegment();
166
171
  return;
@@ -204,6 +209,24 @@ export class ObserverDigestTap {
204
209
  this.safeFlush(slice);
205
210
  }
206
211
  }
212
+ export const OBSERVER_STOPPED_BY_USER_ERROR_NAME = "AgentStoppedByUserError";
213
+ export const OBSERVER_RESUME_STATE_ERROR_NAME = "ResumeAgentStateError";
214
+ export class ObserverStoppedByUserError extends Error {
215
+ constructor(message) {
216
+ super(message);
217
+ this.name = OBSERVER_STOPPED_BY_USER_ERROR_NAME;
218
+ }
219
+ }
220
+ export class ObserverResumeStateError extends Error {
221
+ constructor(message) {
222
+ super(message);
223
+ this.name = OBSERVER_RESUME_STATE_ERROR_NAME;
224
+ }
225
+ }
226
+ function errorName(err) {
227
+ return err instanceof Error ? err.name : undefined;
228
+ }
229
+ export const OBSERVER_FRESH_START_NOTE = "[Note: your previous observation context was lost; this is a fresh start mid-task.]";
207
230
  export class ObserverPairing {
208
231
  state = "armed";
209
232
  observedEnvelopeName;
@@ -273,18 +296,22 @@ export class ObserverPairing {
273
296
  const batch = this.buffer.splice(0, this.buffer.length);
274
297
  try {
275
298
  const digest = renderObserverDigestBatch(this, batch);
276
- if (!this.firstRunDone) {
277
- await this.spawner.spawnFirstRun({ framingPrompt: this.framingPrompt, digest });
278
- this.firstRunDone = true;
279
- }
280
- else {
281
- await this.spawner.deliver({ digest });
282
- }
299
+ await this.deliverBatch(digest);
283
300
  }
284
301
  catch (err) {
285
- this.state = "stopped";
286
- this.buffer = [];
302
+ if (errorName(err) === OBSERVER_STOPPED_BY_USER_ERROR_NAME) {
303
+ this.state = "stopped";
304
+ this.buffer = [];
305
+ this.safeOnError(err);
306
+ return;
307
+ }
287
308
  this.safeOnError(err);
309
+ if (this.buffer.length > 0) {
310
+ const strandedCount = this.buffer.length;
311
+ this.buffer = [];
312
+ this.safeOnError(new Error(`observer delivery: ${strandedCount} segment(s) enqueued mid-fault were stranded and dropped (never attempted) after: ${err instanceof Error ? err.message : String(err)}`));
313
+ }
314
+ return;
288
315
  }
289
316
  }
290
317
  }
@@ -292,6 +319,33 @@ export class ObserverPairing {
292
319
  this.delivering = false;
293
320
  }
294
321
  }
322
+ async deliverBatch(digest) {
323
+ if (!this.firstRunDone) {
324
+ await this.spawner.spawnFirstRun({ framingPrompt: this.framingPrompt, digest });
325
+ this.firstRunDone = true;
326
+ return;
327
+ }
328
+ try {
329
+ await this.spawner.deliver({ digest });
330
+ }
331
+ catch (err) {
332
+ if (errorName(err) !== OBSERVER_RESUME_STATE_ERROR_NAME)
333
+ throw err;
334
+ const restart = this.spawner.restartFresh?.bind(this.spawner);
335
+ if (restart === undefined)
336
+ throw err;
337
+ try {
338
+ await restart({ framingPrompt: `${this.framingPrompt}\n\n${OBSERVER_FRESH_START_NOTE}`, digest });
339
+ this.firstRunDone = true;
340
+ }
341
+ catch (restartErr) {
342
+ this.firstRunDone = false;
343
+ throw restartErr instanceof Error
344
+ ? new Error(`${restartErr.message} (after resume-state loss: ${err instanceof Error ? err.message : String(err)})`, { cause: restartErr })
345
+ : restartErr;
346
+ }
347
+ }
348
+ }
295
349
  retire(state) {
296
350
  if (this.state !== "armed")
297
351
  return;
@@ -326,7 +380,7 @@ export function createObserverReportToolSpec(opts) {
326
380
  name: OBSERVER_REPORT_TOOL_NAME,
327
381
  contract: { contractId: "core.observer_report@1", implementationRevision: "1" },
328
382
  description: OBSERVER_REPORT_DESCRIPTION,
329
- effect: "idempotent",
383
+ effect: "write",
330
384
  parameters: Type.Object({
331
385
  report: Type.String({
332
386
  minLength: 1,
@@ -2,13 +2,34 @@ import { Type } from "typebox";
2
2
  import { defineTool } from "../core/tools.js";
3
3
  import { normalizeAgentName, DURABLE_AGENT_HANDLE_RE, DURABLE_AGENT_HEARTBEAT_MS } from "../core/task-registry.js";
4
4
  import { canAccessAgentRecord } from "../core/background-agent-store.js";
5
- import { isObserverTaskId, OBSERVER_SENDMESSAGE_SENDER_REFUSAL, OBSERVER_SENDMESSAGE_TARGET_REFUSAL, } from "./observer.js";
5
+ import { escapeAttributeValue, escapeEnvelopeTag, isObserverTaskId, OBSERVER_SENDMESSAGE_SENDER_REFUSAL, OBSERVER_SENDMESSAGE_TARGET_REFUSAL, } from "./observer.js";
6
6
  import { SUBAGENT_RESUME_CAP, SubagentRetainLedger, getSessionRetainLedger } from "./retain-ledger.js";
7
7
  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
+ const TEAMMATE_MESSAGE_TAG = "teammate-message";
12
+ function frameTeammateMessage(args) {
13
+ const summaryAttr = args.summary !== undefined ? ` summary="${escapeAttributeValue(args.summary)}"` : "";
14
+ const body = escapeEnvelopeTag(TEAMMATE_MESSAGE_TAG, args.text);
15
+ return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${escapeAttributeValue(args.from)}"${summaryAttr}>\n${body}\n</${TEAMMATE_MESSAGE_TAG}>`;
16
+ }
11
17
  const REVIVE_LEASE_TTL_MS = 5 * 60_000;
18
+ const sendMessageTargetLanes = new Map();
19
+ function withTargetLane(key, fn) {
20
+ const prev = sendMessageTargetLanes.get(key) ?? Promise.resolve();
21
+ const run = prev.then(fn, fn);
22
+ const tail = run.then(() => undefined, () => undefined);
23
+ sendMessageTargetLanes.set(key, tail);
24
+ void tail.then(() => {
25
+ if (sendMessageTargetLanes.get(key) === tail)
26
+ sendMessageTargetLanes.delete(key);
27
+ });
28
+ return run;
29
+ }
30
+ function targetLaneKey(scope, targetId) {
31
+ return JSON.stringify([scope ?? "", targetId]);
32
+ }
12
33
  export function createSendMessageTool(opts) {
13
34
  const tier3Capable = opts.agentStore !== undefined && opts.mailbox !== undefined && opts.reviveSpawn !== undefined;
14
35
  return defineTool({
@@ -16,6 +37,7 @@ export function createSendMessageTool(opts) {
16
37
  contract: { contractId: "core.send_message@1", implementationRevision: "1" },
17
38
  executionMode: "parallel",
18
39
  description: `Send a follow-up message to a previously spawned background agent. ` +
40
+ `Your plain text output is NOT visible to other agents — to communicate, you MUST call this tool. Messages addressed to you are delivered automatically; you don't check an inbox. ` +
19
41
  `'to' is the agent's name (set at spawn via Agent({name})) or its task_id (a…). A RUNNING agent receives the ` +
20
42
  `message at its next turn (queued — never interrupts its current work); a FINISHED agent resumes as a new ` +
21
43
  `background run with its full prior conversation preserved, so don't re-explain what it already knows. Names ` +
@@ -29,7 +51,7 @@ export function createSendMessageTool(opts) {
29
51
  parameters: Type.Object({
30
52
  to: Type.String({ description: 'Recipient: the agent\'s name, or its task_id (a…) returned by the Agent tool with run_in_background. "main" is reserved for the spawning conversation.' }),
31
53
  message: Type.String({ description: "The follow-up request. The agent continues from its full prior context." }),
32
- summary: Type.Optional(Type.String({ maxLength: 200, description: "A short (3-5 word) description of the follow-up." })),
54
+ summary: Type.Optional(Type.String({ maxLength: 200, description: "A 5-10 word summary shown as a preview in the UI (required when message is a string)" })),
33
55
  }),
34
56
  execute: async (args, ctx) => {
35
57
  const a = args;
@@ -39,6 +61,28 @@ export function createSendMessageTool(opts) {
39
61
  return { content: "Message not sent: 'to' was empty. Pass the agent's task_id (a…).", details: { error: "empty to" }, isError: true };
40
62
  if (!message)
41
63
  return { content: "Message not sent: 'message' was empty.", details: { error: "empty message" }, isError: true };
64
+ if (to === "*") {
65
+ return {
66
+ content: 'Message not sent: broadcast (to: "*") is no longer supported — send a message per recipient.',
67
+ details: { error: "broadcast_unsupported", to },
68
+ isError: true,
69
+ };
70
+ }
71
+ if (to.includes("@")) {
72
+ return {
73
+ content: "Message not sent: to must be a bare teammate name — there is only one team per session.",
74
+ details: { error: "qualified_name_unsupported", to },
75
+ isError: true,
76
+ };
77
+ }
78
+ const summaryArg = typeof a.summary === "string" ? a.summary.trim() : "";
79
+ if (summaryArg === "") {
80
+ return {
81
+ content: "Message not sent: summary is required when message is a string — pass a 5-10 word summary of the follow-up.",
82
+ details: { error: "summary_required", to },
83
+ isError: true,
84
+ };
85
+ }
42
86
  const senderId = ctx.taskId ?? opts.owner;
43
87
  if (senderId !== undefined && isObserverTaskId(senderId)) {
44
88
  return { content: OBSERVER_SENDMESSAGE_SENDER_REFUSAL, details: { error: "observer_sender" }, isError: true };
@@ -68,6 +112,17 @@ export function createSendMessageTool(opts) {
68
112
  details: { type: "send-message", status: "uplinked", to: "main", seq: uplinkSeqGlobal },
69
113
  };
70
114
  }
115
+ const hasParent = ctx.parentTaskId !== undefined ||
116
+ opts.uplink !== undefined ||
117
+ opts.senderName !== undefined ||
118
+ opts.siblingRetain !== undefined;
119
+ if (!hasParent) {
120
+ return {
121
+ content: `You are the main conversation — "main" addresses you. Send to a named agent instead.`,
122
+ details: { error: "main_is_self", to },
123
+ isError: true,
124
+ };
125
+ }
71
126
  return {
72
127
  content: `Message not sent: "main" (the spawning conversation) is not a deliverable target here. ` +
73
128
  `Your completion is reported to it automatically — finish your task and your final report will be relayed.`,
@@ -105,10 +160,10 @@ export function createSendMessageTool(opts) {
105
160
  if (row.name === undefined || row.agentType === "fork")
106
161
  return undefined;
107
162
  const whoT3 = handle === to ? `agent ${handle}` : `agent "${to}" (${handle})`;
108
- if (row.status === "killed") {
163
+ if (row.status === "killed" && row.stoppedBy === "user") {
109
164
  return {
110
- content: `Message not sent: ${whoT3} was stopped${row.stoppedBy !== undefined ? ` (by ${row.stoppedBy})` : ""} a stopped agent is not revived. Launch a new agent with the needed context instead.`,
111
- details: { error: "killed", to, ...(row.stoppedBy !== undefined ? { stoppedBy: row.stoppedBy } : {}) },
165
+ content: `Message not sent: ${whoT3} was stopped by the user and was not resumed. Treat its work as cancelled; only start a new agent for it if the user explicitly asks.`,
166
+ details: { error: "killed", to, stoppedBy: row.stoppedBy },
112
167
  isError: true,
113
168
  };
114
169
  }
@@ -244,7 +299,9 @@ export function createSendMessageTool(opts) {
244
299
  details: { error: "mailbox_leased", to },
245
300
  };
246
301
  }
247
- const revivePrompt = lease.messages.map((m) => `<teammate-message teammate_id="${m.from ?? "main"}">\n${m.content}\n</teammate-message>`).join("\n");
302
+ const revivePrompt = lease.messages
303
+ .map((m) => frameTeammateMessage({ from: m.from ?? "main", text: m.content }))
304
+ .join("\n");
248
305
  let spawned;
249
306
  try {
250
307
  spawned = await opts.reviveSpawn({ row: claimed, rev: claimedRev, prompt: revivePrompt });
@@ -354,48 +411,55 @@ export function createSendMessageTool(opts) {
354
411
  return { content: `Message not sent: ${targetId} is a ${row.type} task, not a background agent.`, details: { error: "wrong_type", to }, isError: true };
355
412
  }
356
413
  if (row.status === "running" || row.status === "pending") {
357
- const s2Summary = typeof a.summary === "string" && a.summary.trim() !== "" ? a.summary.trim().slice(0, 200) : undefined;
358
- const fromLabel = opts.senderName ?? senderId ?? "main";
359
- const teammateXml = `<teammate-message teammate_id="${fromLabel}"${s2Summary !== undefined ? ` summary=${JSON.stringify(s2Summary)}` : ""}>\n${message.length > UPLINK_RESULT_MAX ? `${message.slice(0, UPLINK_RESULT_MAX)}\n[message truncated: ${message.length} chars total]` : message}\n</teammate-message>`;
360
- const delivered = await opts.registry.deliverToRunningAgent(targetId, resolvedAccess, {
361
- task_id: senderId ?? "main",
362
- task_type: "background_agent",
363
- status: "event",
364
- summary: `message from ${fromLabel}${s2Summary !== undefined ? `: ${s2Summary}` : ""}`,
365
- result: teammateXml,
366
- seq: ++uplinkSeqGlobal,
367
- }, { priority: "next" });
368
- if (delivered.ok) {
369
- const receiptText = delivered.disposition === "parked"
370
- ? `Message parked for ${who}: the agent finished before reading it — it will be delivered when the agent is next continued. You will be notified of its completion; continue with other work.`
371
- : delivered.disposition === "pending"
372
- ? `Message accepted for ${who} but delivery is UNCONFIRMED (its channel did not confirm within the wait window) — it stays queued and will deliver if the channel binds. You will be notified of the agent's completion either way; resend then if it went unanswered.`
373
- : `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.`;
374
- return {
375
- content: receiptText,
376
- details: { type: "send-message", status: "delivered_running", disposition: delivered.disposition, to, task_id: targetId, ...(s2Summary !== undefined ? { summary: s2Summary } : {}) },
377
- };
378
- }
379
- if (delivered.reason === "no_channel") {
414
+ return await withTargetLane(targetLaneKey(access.scope, targetId), async () => {
415
+ const s2Summary = typeof a.summary === "string" && a.summary.trim() !== "" ? a.summary.trim().slice(0, 200) : undefined;
416
+ const fromLabel = opts.senderName ?? senderId ?? "main";
417
+ const s2Clipped = message.length > UPLINK_RESULT_MAX
418
+ ? `${message.slice(0, UPLINK_RESULT_MAX)}\n[message truncated: ${message.length} chars total]`
419
+ : message;
420
+ const teammateXml = frameTeammateMessage({ from: fromLabel, ...(s2Summary !== undefined ? { summary: s2Summary } : {}), text: s2Clipped });
421
+ const delivered = await opts.registry.deliverToRunningAgent(targetId, resolvedAccess, {
422
+ task_id: senderId ?? "main",
423
+ task_type: "background_agent",
424
+ status: "event",
425
+ summary: `message from ${fromLabel}${s2Summary !== undefined ? `: ${s2Summary}` : ""}`,
426
+ result: teammateXml,
427
+ seq: ++uplinkSeqGlobal,
428
+ }, { priority: "next" });
429
+ if (delivered.ok) {
430
+ const receiptText = delivered.disposition === "parked"
431
+ ? `Message parked for ${who}: the agent finished before reading it — it will be delivered when the agent is next continued. You will be notified of its completion; continue with other work.`
432
+ : delivered.disposition === "pending"
433
+ ? `Message accepted for ${who} but delivery is UNCONFIRMED (its channel did not confirm within the wait window) — it stays queued and will deliver if the channel binds. You will be notified of the agent's completion either way; resend then if it went unanswered.`
434
+ : `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.`;
435
+ return {
436
+ content: receiptText,
437
+ details: { type: "send-message", status: "delivered_running", disposition: delivered.disposition, to, task_id: targetId, ...(s2Summary !== undefined ? { summary: s2Summary } : {}) },
438
+ };
439
+ }
440
+ if (delivered.reason === "no_channel") {
441
+ return {
442
+ content: `Message not sent: ${who} is still running and this deployment has no mid-run delivery channel for it. Wait for its completion notification, then SendMessage to continue it.`,
443
+ details: { error: "still_running", to },
444
+ isError: true,
445
+ };
446
+ }
380
447
  return {
381
- content: `Message not sent: ${who} is still running and this deployment has no mid-run delivery channel for it. Wait for its completion notification, then SendMessage to continue it.`,
382
- details: { error: "still_running", to },
448
+ content: `Message not sent: ${who} just finished (delivery raced its completion). Send again to continue it from its transcript.`,
449
+ details: { error: "settle_race", to },
383
450
  isError: true,
384
451
  };
385
- }
386
- return {
387
- content: `Message not sent: ${who} just finished (delivery raced its completion). Send again to continue it from its transcript.`,
388
- details: { error: "settle_race", to },
389
- isError: true,
390
- };
452
+ });
391
453
  }
392
454
  if (row.status === "killed") {
393
455
  const by = opts.registry.getStopAttribution(targetId);
394
- return {
395
- content: `Message not sent: ${who} was stopped${by ? ` (by ${by})` : ""} — a stopped agent is not resumable. Launch a new agent with the needed context instead.`,
396
- details: { error: "killed", to, ...(by !== undefined ? { stoppedBy: by } : {}) },
397
- isError: true,
398
- };
456
+ if (by === "user") {
457
+ return {
458
+ content: `Message not sent: ${who} was stopped by the user and was not resumed. Treat its work as cancelled; only start a new agent for it if the user explicitly asks.`,
459
+ details: { error: "killed", to, stoppedBy: by },
460
+ isError: true,
461
+ };
462
+ }
399
463
  }
400
464
  const runLedger = ctx.subagentRetain ?? opts.retain;
401
465
  const smLedgerSessionId = ctx.sessionId ?? opts.sessionId;
@@ -409,61 +473,73 @@ export function createSendMessageTool(opts) {
409
473
  return revived;
410
474
  }
411
475
  const ledger = (knows(sessionLedger) ? sessionLedger : undefined) ?? (knows(runLedger) ? runLedger : undefined) ?? (knows(siblingLedger) ? siblingLedger : undefined) ?? runLedger ?? sessionLedger ?? siblingLedger;
412
- if (!ledger || row.toolUseId === undefined) {
476
+ const resumeToolUseId = row.toolUseId;
477
+ if (!ledger || resumeToolUseId === undefined) {
413
478
  return {
414
479
  content: `Message not sent: ${who}'s session was not retained (this run did not enable retainSubagentSessions), so it cannot be continued — relaunch a new agent with the needed context instead.`,
415
480
  details: { error: "not_retained", to },
416
481
  isError: true,
417
482
  };
418
483
  }
419
- const resume = createSubagentResume({
420
- ledger,
421
- parentToolCallId: row.toolUseId,
422
- runner: opts.runner,
423
- ...(opts.notify ? { notify: opts.notify } : {}),
424
- ...(opts.sink ? { sink: opts.sink } : {}),
425
- registry: opts.registry,
426
- taskId: targetId,
427
- taskAccess: resolvedAccess,
428
- ...((ctx.onBackgroundChildEvent ?? opts.onBackgroundChildEvent) ? { bgSink: ctx.onBackgroundChildEvent ?? opts.onBackgroundChildEvent } : {}),
429
- sessionScoped: row.sessionScoped === true,
430
- ...(row.description !== undefined ? { rowDescription: row.description } : {}),
431
- ...(row.name !== undefined ? { rowName: row.name } : {}),
432
- ...(row.agentType !== undefined ? { rowAgentType: row.agentType } : {}),
433
- ...(row.owner !== undefined ? { rowOwner: row.owner } : {}),
434
- ...(row.scope !== undefined ? { rowScope: row.scope } : {}),
435
- ...(row.parentTaskId !== undefined ? { rowParentTaskId: row.parentTaskId } : {}),
436
- ...(row.parentSessionId !== undefined ? { rowParentSessionId: row.parentSessionId } : {}),
437
- ...(row.rootSessionId !== undefined ? { rowRootSessionId: row.rootSessionId } : {}),
438
- ...(opts.notify ? { currentParentNotify: opts.notify } : {}),
439
- });
440
- const summary = typeof a.summary === "string" && a.summary.trim() !== "" ? a.summary.trim().slice(0, 200) : undefined;
441
- const fromPrefix = ctx.parentTaskId !== undefined ? `(message from teammate "${opts.senderName ?? senderId ?? "unknown"}")\n` : "";
442
- try {
443
- const marker = await resume(`${fromPrefix}${summary ? `[${summary}] ${message}` : message}`);
484
+ if (ledger.get(resumeToolUseId)?.running === true) {
444
485
  return {
445
- content: `Message sent ${who} resumed in the background with its prior context intact (correlation marker [${marker}]).\n` +
446
- `You will be notified automatically when it completes; its reply will carry [${marker}]. Continue with other work — do not poll.`,
447
- details: { type: "send-message", status: "resumed", to, task_id: targetId, marker, ...(summary !== undefined ? { summary } : {}) },
486
+ content: `Message not sent: ${who} (or a prior follow-up to it) is still running wait for its completion notification.`,
487
+ details: { error: "steering.still_running", to },
488
+ isError: true,
448
489
  };
449
490
  }
450
- catch (e) {
451
- const code = e?.code;
452
- const text = code === "resume.retain_off"
453
- ? `${who}'s session was not retained (retainSubagentSessions is off) — relaunch a new agent instead.`
454
- : code === "resume.evicted"
455
- ? `${who}'s retained session was evicted (retain TTL / capacity / parent run ended) — relaunch a new agent instead.`
456
- : code === "resume.session_not_found"
457
- ? `${who}'s session no longer exists — relaunch a new agent instead.`
458
- : code === "resume.cap"
459
- ? `${who} reached its resume cap (${SUBAGENT_RESUME_CAP} follow-ups per agent) — relaunch a new agent instead.`
460
- : code === "steering.still_running"
461
- ? `${who} (or a prior follow-up to it) is still running — wait for its completion notification.`
462
- : code === "resume.row_gone"
463
- ? `${who}'s registry row no longer exists (terminal GC) — relaunch a new agent instead.`
464
- : `${e instanceof Error ? e.message : String(e)}`;
465
- return { content: `Message not sent: ${text}`, details: { error: code ?? "resume_failed", to }, isError: true };
466
- }
491
+ return await withTargetLane(targetLaneKey(access.scope, targetId), async () => {
492
+ const resume = createSubagentResume({
493
+ ledger,
494
+ parentToolCallId: resumeToolUseId,
495
+ runner: opts.runner,
496
+ ...(opts.notify ? { notify: opts.notify } : {}),
497
+ ...(opts.sink ? { sink: opts.sink } : {}),
498
+ registry: opts.registry,
499
+ taskId: targetId,
500
+ taskAccess: resolvedAccess,
501
+ ...((ctx.onBackgroundChildEvent ?? opts.onBackgroundChildEvent) ? { bgSink: ctx.onBackgroundChildEvent ?? opts.onBackgroundChildEvent } : {}),
502
+ sessionScoped: row.sessionScoped === true,
503
+ ...(row.description !== undefined ? { rowDescription: row.description } : {}),
504
+ ...(row.name !== undefined ? { rowName: row.name } : {}),
505
+ ...(row.agentType !== undefined ? { rowAgentType: row.agentType } : {}),
506
+ ...(row.owner !== undefined ? { rowOwner: row.owner } : {}),
507
+ ...(row.scope !== undefined ? { rowScope: row.scope } : {}),
508
+ ...(row.parentTaskId !== undefined ? { rowParentTaskId: row.parentTaskId } : {}),
509
+ ...(row.parentSessionId !== undefined ? { rowParentSessionId: row.parentSessionId } : {}),
510
+ ...(row.rootSessionId !== undefined ? { rowRootSessionId: row.rootSessionId } : {}),
511
+ ...(opts.notify ? { currentParentNotify: opts.notify } : {}),
512
+ });
513
+ const summary = typeof a.summary === "string" && a.summary.trim() !== "" ? a.summary.trim().slice(0, 200) : undefined;
514
+ const fromPrefix = ctx.parentTaskId !== undefined ? `(message from teammate "${opts.senderName ?? senderId ?? "unknown"}")\n` : "";
515
+ try {
516
+ const safeSummary = summary !== undefined ? escapeEnvelopeTag(TEAMMATE_MESSAGE_TAG, summary) : undefined;
517
+ const safeMessage = escapeEnvelopeTag(TEAMMATE_MESSAGE_TAG, message);
518
+ const marker = await resume(`${fromPrefix}${safeSummary ? `[${safeSummary}] ${safeMessage}` : safeMessage}`);
519
+ return {
520
+ content: `Message sent — ${who} resumed in the background with its prior context intact (correlation marker [${marker}]).\n` +
521
+ `You will be notified automatically when it completes; its reply will carry [${marker}]. Continue with other work — do not poll.`,
522
+ details: { type: "send-message", status: "resumed", to, task_id: targetId, marker, ...(summary !== undefined ? { summary } : {}) },
523
+ };
524
+ }
525
+ catch (e) {
526
+ const code = e?.code;
527
+ const text = code === "resume.retain_off"
528
+ ? `${who}'s session was not retained (retainSubagentSessions is off) — relaunch a new agent instead.`
529
+ : code === "resume.evicted"
530
+ ? `${who}'s retained session was evicted (retain TTL / capacity / parent run ended) — relaunch a new agent instead.`
531
+ : code === "resume.session_not_found"
532
+ ? `${who}'s session no longer exists — relaunch a new agent instead.`
533
+ : code === "resume.cap"
534
+ ? `${who} reached its resume cap (${SUBAGENT_RESUME_CAP} follow-ups per agent) — relaunch a new agent instead.`
535
+ : code === "steering.still_running"
536
+ ? `${who} (or a prior follow-up to it) is still running — wait for its completion notification.`
537
+ : code === "resume.row_gone"
538
+ ? `${who}'s registry row no longer exists (terminal GC) — relaunch a new agent instead.`
539
+ : `${e instanceof Error ? e.message : String(e)}`;
540
+ return { content: `Message not sent: ${text}`, details: { error: code ?? "resume_failed", to }, isError: true };
541
+ }
542
+ });
467
543
  },
468
544
  });
469
545
  }
@@ -5,16 +5,14 @@ import type { RunInternals } from "../core/runner/prepare-task.js";
5
5
  import type { TaskNotificationPayload } from "../core/task-notification.js";
6
6
  import { FORK_DEFAULT_MAX_TURNS, SESSION_BG_DEFAULT_TIMEOUT_SEC, RETAIN_DEFAULT_TTL_MS, RETAIN_DEFAULT_MAX } from "../config/defaults.js";
7
7
  export { FORK_DEFAULT_MAX_TURNS, SESSION_BG_DEFAULT_TIMEOUT_SEC, RETAIN_DEFAULT_TTL_MS, RETAIN_DEFAULT_MAX };
8
- import { SubagentRetainLedger, getSessionRetainLedger, releaseSessionRetainLedger, type SubagentRetainEntry } from "./retain-ledger.js";
9
- export { SubagentRetainLedger, getSessionRetainLedger, releaseSessionRetainLedger, type SubagentRetainEntry };
10
- export { AGENT_TRANSCRIPT_TOOL_NAME, createAgentTranscriptTool, type AgentTranscriptToolOptions } from "./agent-transcript-tool.js";
11
- export { SEND_MESSAGE_TOOL_NAME, createSendMessageTool, type SendMessageToolOptions } from "./send-message-tool.js";
8
+ import { SubagentRetainLedger } from "./retain-ledger.js";
12
9
  export type { SubagentStep, SubagentEditedFile } from "./subagent-steps.js";
13
10
  export declare function notifyResultField(result: string | undefined): string | undefined;
14
11
  export declare function inheritedManifestScopeFor(snapshot: readonly unknown[] | undefined): RunInternals["inheritedManifestScope"];
15
12
  export declare const DEFAULT_SUBAGENT_TOOL_NAME = "Agent";
16
13
  export declare const LEGACY_SUBAGENT_TOOL_NAME = "Task";
17
14
  export declare const EXTRA_TOOLS_MAX_FACTORY_CALLS_PER_TREE = 64;
15
+ export declare const DEFAULT_SUBAGENT_MAX_DEPTH = 3;
18
16
  export declare const EXTRA_TOOLS_FAILED_NOTE = "note: extraTools evaluation failed \u2014 the injected tool set was skipped for this spawn.";
19
17
  export declare const EXTRA_TOOLS_BUDGET_NOTE = "note: extraTools evaluation was skipped \u2014 this spawn tree exhausted its factory-call budget (64); no dynamic tools were injected for this spawn.";
20
18
  export interface SubagentToolStats {
@@ -127,8 +125,17 @@ export interface SubagentSpawnContext {
127
125
  agentType?: string;
128
126
  depth: number;
129
127
  }
128
+ export declare function normalizeSubagentType(value: string): string;
130
129
  export declare const SUBAGENT_SYSTEM_NOTE: string;
131
130
  export declare const FORK_DIRECTIVE_FRAME = "<fork-boilerplate>\nYou are a worker fork. The transcript above is the parent's history \u2014 inherited reference, not your situation. You are NOT a continuation of that agent. Execute ONE directive, then stop.\n\nHard rules:\n- Do NOT spawn subagents with the Agent tool. The \"default to forking\" guidance is for the parent; you ARE the fork, execute directly.\n- One shot: report once and stop. No follow-up questions, no proposed next steps, no waiting for the user.\n\nGuidelines (your directive may override any of these):\n- Stay in scope. Other forks may be handling adjacent work; if you spot something outside your directive, note it in a sentence and move on.\n- Open with one line restating your task, so the parent can spot scope drift at a glance.\n- Be concise \u2014 as short as the answer allows, no shorter. Plain text, no preamble, no meta-commentary.\n- If you committed changes, list the paths and commit hashes in your report.\n</fork-boilerplate>\n\nYour directive: ";
131
+ export declare function forkWorktreeTranslationNote(parentCwd: string | undefined, worktreeDir: string): string;
132
+ export declare function asyncLaunchedReceipt(p: {
133
+ taskId: string;
134
+ workingLine: string;
135
+ notify: boolean;
136
+ oneShot?: boolean;
137
+ notes?: (string | undefined)[];
138
+ }): string;
132
139
  export declare function createSubagentTool(opts: SubagentToolOptions): ToolSpec;
133
140
  export declare function agentToolsNote(def: {
134
141
  allowTools?: string[];