@sema-agent/core 5.16.0 → 5.17.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 (70) hide show
  1. package/CHANGELOG.md +224 -0
  2. package/dist/agents/peer-admission.d.ts +58 -0
  3. package/dist/agents/peer-admission.js +175 -0
  4. package/dist/agents/retain-ledger.d.ts +1 -1
  5. package/dist/agents/retain-ledger.js +9 -1
  6. package/dist/agents/send-message-tool.d.ts +8 -0
  7. package/dist/agents/send-message-tool.js +171 -21
  8. package/dist/agents/subagent.d.ts +13 -0
  9. package/dist/agents/subagent.js +90 -5
  10. package/dist/core/ask-question.js +16 -1
  11. package/dist/core/canonical-json.js +176 -14
  12. package/dist/core/checkpoint-store.d.ts +14 -0
  13. package/dist/core/checkpoint-store.js +73 -0
  14. package/dist/core/hooks.d.ts +4 -1
  15. package/dist/core/hooks.js +24 -6
  16. package/dist/core/mailbox-store.d.ts +2 -0
  17. package/dist/core/mailbox-store.js +2 -2
  18. package/dist/core/mcp.d.ts +1 -0
  19. package/dist/core/mcp.js +15 -3
  20. package/dist/core/runner/prepare-task.d.ts +4 -0
  21. package/dist/core/runner/prepare-task.js +169 -46
  22. package/dist/core/runner/runtask.js +13 -1
  23. package/dist/core/runner/turn-attachments.d.ts +2 -1
  24. package/dist/core/runner/turn-attachments.js +9 -6
  25. package/dist/core/session-reconcile.js +19 -1
  26. package/dist/core/shared-memory/contract.d.ts +17 -0
  27. package/dist/core/shared-memory/contract.js +138 -0
  28. package/dist/core/shared-memory/normalize.d.ts +73 -0
  29. package/dist/core/shared-memory/normalize.js +259 -0
  30. package/dist/core/shared-memory/tools.d.ts +7 -0
  31. package/dist/core/shared-memory/tools.js +289 -0
  32. package/dist/core/shared-memory/types.d.ts +95 -0
  33. package/dist/core/shared-memory/types.js +18 -0
  34. package/dist/core/task-notification.d.ts +3 -0
  35. package/dist/core/task-registry-agent.d.ts +1 -1
  36. package/dist/core/task-registry-agent.js +2 -1
  37. package/dist/core/task-registry.d.ts +1 -1
  38. package/dist/core/task-registry.js +2 -0
  39. package/dist/core/tool-policy.d.ts +9 -0
  40. package/dist/core/tool-policy.js +28 -8
  41. package/dist/core/types.d.ts +8 -0
  42. package/dist/core/untrusted-text.d.ts +1 -0
  43. package/dist/core/untrusted-text.js +10 -0
  44. package/dist/core/wiring-manifest.js +2 -2
  45. package/dist/engine/harness/agent-harness.d.ts +1 -0
  46. package/dist/engine/harness/agent-harness.js +21 -2
  47. package/dist/engine/llm/validation.js +121 -5
  48. package/dist/engine/loop/agent-loop.d.ts +2 -0
  49. package/dist/engine/loop/agent-loop.js +17 -4
  50. package/dist/index.d.ts +4 -0
  51. package/dist/index.js +4 -0
  52. package/dist/prompts/supervisor.d.ts +1 -1
  53. package/dist/prompts/supervisor.js +1 -1
  54. package/dist/stores/cc/mailbox-store.js +4 -0
  55. package/dist/stores/file/checkpoint-store.d.ts +1 -0
  56. package/dist/stores/file/checkpoint-store.js +1 -0
  57. package/dist/stores/file/mailbox-store.js +2 -2
  58. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -0
  59. package/dist/tools/fs/bash-readonly-classifier.js +11 -10
  60. package/dist/tools/fs/fs-bash.js +6 -6
  61. package/dist/tools/fs/fs-read.d.ts +1 -1
  62. package/dist/tools/fs/fs-read.js +4 -3
  63. package/dist/tools/fs/fs-shared.d.ts +3 -0
  64. package/dist/tools/fs/fs-shared.js +8 -1
  65. package/dist/tools/fs/fs-write.js +8 -8
  66. package/dist/tools/fs/index.d.ts +1 -0
  67. package/dist/tools/fs/index.js +1 -1
  68. package/dist/tools/fs/safety.d.ts +1 -0
  69. package/dist/tools/fs/safety.js +9 -2
  70. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { canAccessAgentRecord, clearRevivedRowTerminalPayload } from "../core/ba
5
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
+ import { appendHopToken, judgePeerAdmission, peerAxisToken, resolvePeerAdmissionConfig, PEER_MESSAGE_NOTICE, } from "./peer-admission.js";
8
9
  export const SEND_MESSAGE_TOOL_NAME = "SendMessage";
9
10
  let uplinkSeqGlobal = Date.now();
10
11
  const UPLINK_RESULT_MAX = 8000;
@@ -104,23 +105,55 @@ export function createSendMessageTool(opts) {
104
105
  if (isObserverTaskId(to)) {
105
106
  return { content: OBSERVER_SENDMESSAGE_TARGET_REFUSAL, details: { error: "observer_target", to }, isError: true };
106
107
  }
108
+ const admissionConfig = resolvePeerAdmissionConfig(opts.admission);
109
+ const guardScope = ctx.principal ?? opts.scope;
110
+ const selfRef = ctx.peerSelfRef ?? opts.peerSelf;
111
+ const directMountSessionId = ctx.sessionId ?? opts.sessionId;
112
+ const directMountTaskId = ctx.taskId ?? opts.owner;
113
+ const senderKey = selfRef?.current.key ??
114
+ (directMountSessionId !== undefined
115
+ ? peerAxisToken(guardScope, "s", directMountSessionId)
116
+ : directMountTaskId !== undefined
117
+ ? peerAxisToken(guardScope, "t", directMountTaskId)
118
+ : undefined);
119
+ const inboundChain = (ctx.peerInboundChainRef ?? opts.peerInbound)?.current ?? [];
120
+ const prospectiveChain = appendHopToken(inboundChain, senderKey);
121
+ const admitFor = (recipientKey, ownTokens) => judgePeerAdmission(guardScope, recipientKey, { senderKey, body: message, prospectiveChain, ownTokens }, admissionConfig);
122
+ const DEDUP_RETRY_NOTE = "Note: resending the identical text within the dedup window reads as duplicate — rephrase or wait it out.";
123
+ const admissionRefusal = (reason, whoLabel) => {
124
+ const text = reason === "rate_limited"
125
+ ? `you are sending messages to ${whoLabel} faster than its rate limit — the budget refills on its own; slow down and resend in a moment.`
126
+ : reason === "duplicate"
127
+ ? `an identical message was already sent to ${whoLabel} moments ago — if this is genuinely new, rephrase it or wait out the dedup window.`
128
+ : reason === "hop_loop"
129
+ ? `this message has already passed through ${whoLabel} too many times (a forwarding loop) — stop relaying it; act on it or drop it.`
130
+ : `this message's forwarding chain is too long (runaway relay) — stop relaying it; act on it or drop it.`;
131
+ return { content: `Message not sent: ${text}`, details: { error: reason, to }, isError: true };
132
+ };
107
133
  if (normalizeAgentName(to) === "main") {
108
134
  if (opts.uplink && senderId !== undefined) {
135
+ const uplinkIdentity = opts.uplinkRecipient?.current;
136
+ if (uplinkIdentity?.key !== undefined) {
137
+ const verdict = admitFor(uplinkIdentity.key, uplinkIdentity.ownTokens);
138
+ if (!verdict.ok)
139
+ return admissionRefusal(verdict.reason, "main");
140
+ }
109
141
  try {
110
142
  opts.uplink({
111
143
  task_id: senderId,
112
144
  task_type: "background_agent",
113
145
  status: "event",
114
146
  summary: `message from ${senderLabel}: ${summary}`,
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,
147
+ 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}\n\n${PEER_MESSAGE_NOTICE}`,
116
148
  seq: ++uplinkSeqGlobal,
149
+ peer: { hopChain: prospectiveChain },
117
150
  }, { priority: "next" });
118
151
  }
119
152
  catch (e) {
120
- return { content: `Message not sent: the parent's notification lane rejected it (${e instanceof Error ? e.message : String(e)}).`, details: { error: "uplink_failed", to }, isError: true };
153
+ return { content: `Message not sent: the parent's notification lane rejected it (${e instanceof Error ? e.message : String(e)}). ${DEDUP_RETRY_NOTE}`, details: { error: "uplink_failed", to }, isError: true };
121
154
  }
122
155
  return {
123
- content: `Message sent to main — it will reach the spawning conversation at its next turn boundary. Continue with your task; do not wait for a reply.`,
156
+ content: `Message sent to main — queued for the spawning conversation at its next turn boundary. If that conversation finishes before reading it, the message may not survive. Continue with your task; do not wait for a reply.`,
124
157
  details: { type: "send-message", status: "uplinked", to: "main", seq: uplinkSeqGlobal },
125
158
  };
126
159
  }
@@ -207,9 +240,102 @@ export function createSendMessageTool(opts) {
207
240
  }
208
241
  if (row.sessionId === undefined)
209
242
  return undefined;
243
+ const t3RecipientKey = peerAxisToken(scope, "h", handle);
244
+ const t3Verdict = admitFor(t3RecipientKey, [t3RecipientKey, peerAxisToken(scope, "s", row.sessionId)]);
245
+ if (!t3Verdict.ok)
246
+ return admissionRefusal(t3Verdict.reason, whoT3);
247
+ let boxFull = false;
248
+ try {
249
+ boxFull = (await opts.mailbox.peekCount(scope, handle)) >= admissionConfig.maxQueuedPeerMessages;
250
+ }
251
+ catch {
252
+ }
253
+ if (boxFull) {
254
+ const queueFullReceipt = (guidance) => ({
255
+ content: `Message not sent: ${whoT3}'s mailbox is at its queued-message limit (${admissionConfig.maxQueuedPeerMessages}) — the message was NOT queued. ${guidance} ${DEDUP_RETRY_NOTE}`,
256
+ details: { error: "queue_full", to },
257
+ isError: true,
258
+ });
259
+ if (!opts.registry.beginDurableClaim(handle)) {
260
+ return queueFullReceipt(`Another lifecycle operation currently owns this agent's claim — resend in a moment.`);
261
+ }
262
+ try {
263
+ const now = Date.now();
264
+ const nextSeq = (row.seq ?? 1) + 1;
265
+ const claimed = { ...row, status: "running", writerId: opts.registry.writerId, writerEpoch: (row.writerEpoch ?? 0) + 1, updatedAt: now, seq: nextSeq };
266
+ clearRevivedRowTerminalPayload(claimed);
267
+ let won = false;
268
+ try {
269
+ won = await opts.agentStore.update(handle, scope, claimed, { rev: row.rev });
270
+ }
271
+ catch {
272
+ won = false;
273
+ }
274
+ if (!won) {
275
+ return queueFullReceipt(`Another delivery claimed the agent's record first — this send did not start a backlog drain; a later resend will try again.`);
276
+ }
277
+ const claimedRev = row.rev + 1;
278
+ const rollbackDrain = async () => {
279
+ try {
280
+ await opts.agentStore.update(handle, scope, { ...row, updatedAt: Date.now() }, { rev: claimedRev });
281
+ }
282
+ catch {
283
+ }
284
+ };
285
+ try {
286
+ await opts.runner.sessions.acquire(row.sessionId, { requireExisting: true });
287
+ }
288
+ catch (e) {
289
+ await rollbackDrain();
290
+ if (e?.code === "not_found") {
291
+ return queueFullReceipt(`Its transcript session no longer exists (evicted or reaped), so a revival cannot drain the backlog.`);
292
+ }
293
+ return queueFullReceipt(`Its transcript store did not answer (${e instanceof Error ? e.message : String(e)}) — this send did not start a backlog drain; a later resend will try again.`);
294
+ }
295
+ const drainLeaseOwner = `${opts.registry.writerId}:${ctx.toolCallId}`;
296
+ let drainLease = null;
297
+ try {
298
+ drainLease = await opts.mailbox.claimLease(scope, handle, drainLeaseOwner, REVIVE_LEASE_TTL_MS);
299
+ }
300
+ catch {
301
+ drainLease = null;
302
+ }
303
+ if (drainLease === null) {
304
+ await rollbackDrain();
305
+ return queueFullReceipt(`No backlog batch was claimable this time — this send did not start a backlog drain; a later resend will try again.`);
306
+ }
307
+ const drainPrompt = `${drainLease.messages.map((m) => frameTeammateMessage({ from: m.from ?? "main", text: m.content })).join("\n")}\n\n${PEER_MESSAGE_NOTICE}`;
308
+ const drainSeed = drainLease.messages[drainLease.messages.length - 1]?.hopChain ?? [];
309
+ let drainSpawned;
310
+ try {
311
+ drainSpawned = await opts.reviveSpawn({ row: claimed, rev: claimedRev, prompt: drainPrompt, peerSeed: { hopChain: [...drainSeed] } });
312
+ }
313
+ catch (e) {
314
+ drainSpawned = { isError: true, content: e instanceof Error ? e.message : String(e) };
315
+ }
316
+ if (drainSpawned.isError === true) {
317
+ try {
318
+ await opts.mailbox.releaseLease(scope, handle, drainLeaseOwner);
319
+ }
320
+ catch {
321
+ }
322
+ await rollbackDrain();
323
+ return queueFullReceipt(`Reviving it to drain the backlog failed (${drainSpawned.content}) — this send did not start a backlog drain; a later resend will try again.`);
324
+ }
325
+ try {
326
+ await opts.mailbox.ack(scope, handle, drainLeaseOwner, drainLease.maxSeq);
327
+ }
328
+ catch {
329
+ }
330
+ return queueFullReceipt(`The agent is being revived to digest its ${drainLease.messages.length} queued message(s) — resend after it settles.`);
331
+ }
332
+ finally {
333
+ opts.registry.endDurableClaim(handle);
334
+ }
335
+ }
210
336
  if (!opts.registry.beginDurableClaim(handle)) {
211
337
  return {
212
- content: `Message not sent: ${whoT3} is being revived or recycled right now — send again in a moment.`,
338
+ content: `Message not sent: ${whoT3} is being revived or recycled right now — send again in a moment. ${DEDUP_RETRY_NOTE}`,
213
339
  details: { error: "claim_contended", to },
214
340
  isError: true,
215
341
  };
@@ -235,13 +361,13 @@ export function createSendMessageTool(opts) {
235
361
  }
236
362
  if (fresh?.status === "running") {
237
363
  return {
238
- content: `Message not sent: ${whoT3} was just revived by another delivery — it is running now. Send again to reach the running agent.`,
364
+ content: `Message not sent: ${whoT3} was just revived by another delivery — it is running now. Send again to reach the running agent. ${DEDUP_RETRY_NOTE}`,
239
365
  details: { error: "claim_lost", to },
240
366
  isError: true,
241
367
  };
242
368
  }
243
369
  return {
244
- content: `Message not sent: ${whoT3}'s durable record changed underneath this delivery — send again.`,
370
+ content: `Message not sent: ${whoT3}'s durable record changed underneath this delivery — send again. ${DEDUP_RETRY_NOTE}`,
245
371
  details: { error: "claim_lost", to },
246
372
  isError: true,
247
373
  };
@@ -267,19 +393,19 @@ export function createSendMessageTool(opts) {
267
393
  };
268
394
  }
269
395
  return {
270
- content: `Message not sent: ${whoT3}'s transcript store did not answer (${e instanceof Error ? e.message : String(e)}) — nothing was parked; send again.`,
396
+ content: `Message not sent: ${whoT3}'s transcript store did not answer (${e instanceof Error ? e.message : String(e)}) — nothing was parked; send again. ${DEDUP_RETRY_NOTE}`,
271
397
  details: { error: "session_store_unavailable", to },
272
398
  isError: true,
273
399
  };
274
400
  }
275
401
  const clipped = clipCarrierMessage(message);
276
402
  try {
277
- await opts.mailbox.append(scope, handle, { from: senderLabel, content: `[${summary}] ${clipped}`, sentAt: now });
403
+ await opts.mailbox.append(scope, handle, { from: senderLabel, content: `[${summary}] ${clipped}`, sentAt: now, hopChain: prospectiveChain });
278
404
  }
279
405
  catch (e) {
280
406
  await rollback();
281
407
  return {
282
- content: `Message not sent: the durable mailbox refused the message (${e instanceof Error ? e.message : String(e)}) — nothing was parked and the agent was not revived.`,
408
+ content: `Message not sent: the durable mailbox refused the message (${e instanceof Error ? e.message : String(e)}) — nothing was parked and the agent was not revived. ${DEDUP_RETRY_NOTE}`,
283
409
  details: { error: "mailbox_failed", to },
284
410
  isError: true,
285
411
  };
@@ -299,12 +425,13 @@ export function createSendMessageTool(opts) {
299
425
  details: { error: "mailbox_leased", to },
300
426
  };
301
427
  }
302
- const revivePrompt = lease.messages
428
+ const revivePrompt = `${lease.messages
303
429
  .map((m) => frameTeammateMessage({ from: m.from ?? "main", text: m.content }))
304
- .join("\n");
430
+ .join("\n")}\n\n${PEER_MESSAGE_NOTICE}`;
431
+ const reviveSeed = lease.messages[lease.messages.length - 1]?.hopChain ?? [];
305
432
  let spawned;
306
433
  try {
307
- spawned = await opts.reviveSpawn({ row: claimed, rev: claimedRev, prompt: revivePrompt });
434
+ spawned = await opts.reviveSpawn({ row: claimed, rev: claimedRev, prompt: revivePrompt, peerSeed: { hopChain: [...reviveSeed] } });
308
435
  }
309
436
  catch (e) {
310
437
  spawned = { isError: true, content: e instanceof Error ? e.message : String(e), details: { error: "revive_failed" } };
@@ -418,8 +545,12 @@ export function createSendMessageTool(opts) {
418
545
  }
419
546
  if (row.status === "running" || row.status === "pending") {
420
547
  return await withTargetLane(targetLaneKey(access.scope, targetId), async () => {
548
+ const liveRecipientKey = peerAxisToken(resolvedAccess.scope, "h", targetId);
549
+ const liveVerdict = admitFor(liveRecipientKey, [liveRecipientKey]);
550
+ if (!liveVerdict.ok)
551
+ return admissionRefusal(liveVerdict.reason, who);
421
552
  const s2Clipped = clipCarrierMessage(message);
422
- const teammateXml = frameTeammateMessage({ from: senderLabel, summary, text: s2Clipped });
553
+ const teammateXml = `${frameTeammateMessage({ from: senderLabel, summary, text: s2Clipped })}\n\n${PEER_MESSAGE_NOTICE}`;
423
554
  const delivered = await opts.registry.deliverToRunningAgent(targetId, resolvedAccess, {
424
555
  task_id: senderLabel,
425
556
  task_type: "background_agent",
@@ -427,13 +558,14 @@ export function createSendMessageTool(opts) {
427
558
  summary: `message from ${senderLabel}: ${summary}`,
428
559
  result: teammateXml,
429
560
  seq: ++uplinkSeqGlobal,
561
+ peer: { hopChain: prospectiveChain },
430
562
  }, { priority: "next" });
431
563
  if (delivered.ok) {
432
564
  const receiptText = delivered.disposition === "parked"
433
565
  ? `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.`
434
566
  : delivered.disposition === "pending"
435
- ? `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.`
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.`;
567
+ ? `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. ${DEDUP_RETRY_NOTE}`
568
+ : `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. ${DEDUP_RETRY_NOTE}`;
437
569
  return {
438
570
  content: receiptText,
439
571
  details: { type: "send-message", status: "delivered_running", disposition: delivered.disposition, to, task_id: targetId, summary },
@@ -441,15 +573,22 @@ export function createSendMessageTool(opts) {
441
573
  }
442
574
  if (delivered.reason === "no_channel") {
443
575
  return {
444
- 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.`,
576
+ 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. ${DEDUP_RETRY_NOTE}`,
445
577
  details: { error: "still_running", to },
446
578
  isError: true,
447
579
  };
448
580
  }
581
+ if (delivered.reason === "queue_full") {
582
+ return {
583
+ content: `Message not sent: ${who} is still starting up and its startup message buffer is full — resend in a moment. ${DEDUP_RETRY_NOTE}`,
584
+ details: { error: "queue_full", to },
585
+ isError: true,
586
+ };
587
+ }
449
588
  const nowRow = opts.registry.getAccessibleTask(targetId, resolvedAccess);
450
589
  if (nowRow?.status === "parked") {
451
590
  return {
452
- content: `Message not sent: ${who} is parked on a pending approval — it resumes when the approval is decided, not by message delivery. Send again after it resumes.`,
591
+ content: `Message not sent: ${who} is parked on a pending approval — it resumes when the approval is decided, not by message delivery. Send again after it resumes. ${DEDUP_RETRY_NOTE}`,
453
592
  details: { error: "parked_pending_approval", to },
454
593
  isError: true,
455
594
  };
@@ -457,12 +596,12 @@ export function createSendMessageTool(opts) {
457
596
  const stillLive = nowRow !== undefined && (nowRow.status === "running" || nowRow.status === "pending");
458
597
  return stillLive
459
598
  ? {
460
- content: `Message not sent: ${who} is still starting up (it has not begun reading messages yet) — the message was not delivered. Send again shortly.`,
599
+ content: `Message not sent: ${who} is still starting up (it has not begun reading messages yet) — the message was not delivered. Send again shortly. ${DEDUP_RETRY_NOTE}`,
461
600
  details: { error: "settle_race", to },
462
601
  isError: true,
463
602
  }
464
603
  : {
465
- content: `Message not sent: ${who} just finished (delivery raced its completion). Send again to continue it from its transcript.`,
604
+ content: `Message not sent: ${who} just finished (delivery raced its completion). Send again to continue it from its transcript. ${DEDUP_RETRY_NOTE}`,
466
605
  details: { error: "settle_race", to },
467
606
  isError: true,
468
607
  };
@@ -506,10 +645,20 @@ export function createSendMessageTool(opts) {
506
645
  };
507
646
  }
508
647
  return await withTargetLane(targetLaneKey(access.scope, targetId), async () => {
648
+ const l3Entry = ledger.get(resumeToolUseId);
649
+ const l3RecipientKey = peerAxisToken(access.scope, "h", targetId);
650
+ const l3Verdict = admitFor(l3RecipientKey, [
651
+ l3RecipientKey,
652
+ ...(l3Entry?.childSessionId !== undefined ? [peerAxisToken(access.scope, "s", l3Entry.childSessionId)] : []),
653
+ ]);
654
+ if (!l3Verdict.ok)
655
+ return admissionRefusal(l3Verdict.reason, who);
509
656
  const resume = createSubagentResume({
510
657
  ledger,
511
658
  parentToolCallId: resumeToolUseId,
512
659
  runner: opts.runner,
660
+ peerOrigin: { hopChain: prospectiveChain },
661
+ ...(opts.notify !== undefined && selfRef !== undefined ? { currentParentPeerRef: selfRef } : {}),
513
662
  ...(opts.notify ? { notify: opts.notify } : {}),
514
663
  ...(opts.sink ? { sink: opts.sink } : {}),
515
664
  registry: opts.registry,
@@ -558,10 +707,11 @@ export function createSendMessageTool(opts) {
558
707
  : code === "resume.cap"
559
708
  ? `${who} reached its resume cap (${SUBAGENT_RESUME_CAP} follow-ups per agent) — relaunch a new agent instead.`
560
709
  : code === "steering.still_running"
561
- ? `${who} (or a prior follow-up to it) is still running — wait for its completion notification.`
710
+ ?
711
+ `${who} (or a prior follow-up to it) is still running — wait for its completion notification. ${DEDUP_RETRY_NOTE}`
562
712
  : code === "resume.row_gone"
563
713
  ? `${who}'s registry row no longer exists (terminal GC) — relaunch a new agent instead.`
564
- : `${e instanceof Error ? e.message : String(e)}`;
714
+ : `${e instanceof Error ? e.message : String(e)} ${DEDUP_RETRY_NOTE}`;
565
715
  return { content: `Message not sent: ${text}`, details: { error: code ?? "resume_failed", to }, isError: true };
566
716
  }
567
717
  });
@@ -6,8 +6,17 @@ import type { TaskNotificationPayload } from "../core/task-notification.js";
6
6
  import { RETAIN_DEFAULT_TTL_MS, RETAIN_DEFAULT_MAX } from "../config/defaults.js";
7
7
  export { RETAIN_DEFAULT_TTL_MS, RETAIN_DEFAULT_MAX };
8
8
  import { SubagentRetainLedger } from "./retain-ledger.js";
9
+ import { type PeerSelfRef } from "./peer-admission.js";
9
10
  export type { SubagentStep, SubagentEditedFile } from "./subagent-steps.js";
10
11
  export declare function notifyResultField(result: string | undefined): string | undefined;
12
+ interface ReviewSample {
13
+ text: string;
14
+ readChars: number;
15
+ totalChars: number;
16
+ sampled: boolean;
17
+ gaps: number;
18
+ }
19
+ export declare function layeredReviewSample(text: string, budget?: number, windows?: number): ReviewSample;
11
20
  export declare function inheritedManifestScopeFor(snapshot: readonly unknown[] | undefined): RunInternals["inheritedManifestScope"];
12
21
  export declare const DEFAULT_SUBAGENT_TOOL_NAME = "Agent";
13
22
  export declare const EXTRA_TOOLS_MAX_FACTORY_CALLS_PER_TREE = 64;
@@ -100,6 +109,10 @@ export declare function createSubagentResume(deps: {
100
109
  currentAutoModeReview?: {
101
110
  decider: import("../core/auto-mode.js").AutoModeDecider;
102
111
  };
112
+ peerOrigin?: {
113
+ hopChain: string[];
114
+ };
115
+ currentParentPeerRef?: PeerSelfRef;
103
116
  taskId?: string;
104
117
  taskAccess?: import("../core/task-registry.js").TaskAccess;
105
118
  bgSink?: (event: import("../core/types.js").BackgroundChildEvent) => void;
@@ -34,6 +34,7 @@ function delegatedCostField(stats) {
34
34
  }
35
35
  export { RETAIN_DEFAULT_TTL_MS, RETAIN_DEFAULT_MAX };
36
36
  import { SUBAGENT_RESUME_CAP, SubagentRetainLedger, getOrCreateSessionRetainLedger, ensureSessionReapHook, createResumePrompt, } from "./retain-ledger.js";
37
+ import { createPeerInboundChainRef, createPeerSelfRef } from "./peer-admission.js";
37
38
  import { recordRosterSpawn } from "./roster-store.js";
38
39
  import { ObserverDigestTap, ObserverPairing, createObserverReportToolSpec, markObserverTaskId, unmarkObserverTaskId, isObserverTaskId, ObserverResumeStateError, ObserverStoppedByUserError, observerFramingPrompt, observerSlug, resolveObserverDeclaration, } from "./observer.js";
39
40
  import { SubagentStepRecorder } from "./subagent-steps.js";
@@ -54,6 +55,66 @@ export function notifyResultField(result) {
54
55
  ? `${result.slice(0, BG_AGENT_NOTIFY_RESULT_MAX)}\n[result truncated: ${result.length} chars total — call TaskOutput for the full text]`
55
56
  : result;
56
57
  }
58
+ const HANDBACK_REVIEW_SAMPLE_BUDGET = 12_000;
59
+ const HANDBACK_REVIEW_SAMPLE_WINDOWS = 5;
60
+ const HANDBACK_REVIEW_SAMPLE_MAX_WINDOWS = 16;
61
+ const HANDBACK_REVIEW_MARKER_COST = 32;
62
+ const REVIEW_MARKER_SCAR = String.fromCharCode(0xff3b);
63
+ function defuseGapMarkers(slice) {
64
+ return slice.replace(/\[…/g, `${REVIEW_MARKER_SCAR}…`);
65
+ }
66
+ function surrogateSafeSlice(text, from, width, anchorEnd = false) {
67
+ const isLow = (i) => text.charCodeAt(i) >= 0xdc00 && text.charCodeAt(i) <= 0xdfff;
68
+ const isHigh = (i) => text.charCodeAt(i) >= 0xd800 && text.charCodeAt(i) <= 0xdbff;
69
+ let start = from;
70
+ if (start > 0 && start < text.length && isLow(start) && isHigh(start - 1))
71
+ start--;
72
+ if (anchorEnd)
73
+ return { text: text.slice(start), start, end: text.length };
74
+ let end = Math.min(start + width, text.length);
75
+ if (end > start && end < text.length && isHigh(end - 1) && isLow(end))
76
+ end--;
77
+ return { text: text.slice(start, end), start, end };
78
+ }
79
+ export function layeredReviewSample(text, budget = HANDBACK_REVIEW_SAMPLE_BUDGET, windows = HANDBACK_REVIEW_SAMPLE_WINDOWS) {
80
+ const total = text.length;
81
+ const budgetChars = Number.isFinite(budget) ? Math.max(4, Math.floor(budget)) : HANDBACK_REVIEW_SAMPLE_BUDGET;
82
+ if (total <= budgetChars)
83
+ return { text, readChars: total, totalChars: total, sampled: false, gaps: 0 };
84
+ const requested = Number.isFinite(windows) ? Math.floor(windows) : HANDBACK_REVIEW_SAMPLE_WINDOWS;
85
+ const count = Math.max(2, Math.min(Math.max(2, requested), HANDBACK_REVIEW_SAMPLE_MAX_WINDOWS, Math.floor(budgetChars / HANDBACK_REVIEW_MARKER_COST)));
86
+ const size = Math.floor(budgetChars / count);
87
+ const span = total - size;
88
+ const parts = [];
89
+ let readChars = 0;
90
+ let prevEnd = 0;
91
+ let gaps = 0;
92
+ for (let i = 0; i < count; i++) {
93
+ const start = Math.round((span * i) / (count - 1));
94
+ const slice = surrogateSafeSlice(text, start, size, i === count - 1);
95
+ if (i > 0 && slice.start > prevEnd) {
96
+ parts.push(`\n[… ${slice.start - prevEnd} chars not shown …]\n`);
97
+ gaps++;
98
+ }
99
+ parts.push(defuseGapMarkers(slice.text));
100
+ readChars += slice.text.length;
101
+ prevEnd = slice.end;
102
+ }
103
+ const out = parts.join("");
104
+ if (out.length >= total)
105
+ return { text, readChars: total, totalChars: total, sampled: false, gaps: 0 };
106
+ return { text: out, readChars, totalChars: total, sampled: true, gaps };
107
+ }
108
+ function reviewCoverageNote(fields) {
109
+ const sampled = fields.filter((f) => f.sample.sampled);
110
+ if (sampled.length === 0)
111
+ return undefined;
112
+ return (sampled
113
+ .map((f) => `${f.field}: the classifier was shown ${f.sample.readChars} of ${f.sample.totalChars} characters (UTF-16 code units) ` +
114
+ `(layered head/middle/tail sample; the ${f.sample.gaps} skipped span(s) are marked in place as ` +
115
+ `"[… N chars not shown …]" — any further such marker in the text is the child's own writing, not this sampler's)`)
116
+ .join("; ") + " — the unshown spans were NOT reviewed; treat them as unknown, not as benign.");
117
+ }
57
118
  const HANDBACK_ASK_MESSAGE = "Subagent has finished and is handing back control to the main agent. Review the subagent's work and flag if any action may violate security policy.";
58
119
  async function reviewHandback(opts) {
59
120
  const { review, evidence, signal } = opts;
@@ -63,15 +124,22 @@ async function reviewHandback(opts) {
63
124
  return undefined;
64
125
  if (!evidence.result && !evidence.steps && !evidence.edits && !evidence.partialText)
65
126
  return undefined;
127
+ const resultSample = evidence.result ? layeredReviewSample(evidence.result) : undefined;
128
+ const partialSample = evidence.partialText ? layeredReviewSample(evidence.partialText) : undefined;
129
+ const coverage = reviewCoverageNote([
130
+ ...(resultSample ? [{ field: "result", sample: resultSample }] : []),
131
+ ...(partialSample ? [{ field: "partialFindings", sample: partialSample }] : []),
132
+ ]);
66
133
  const verdict = await review.decider
67
134
  .decide({
68
135
  req: {
69
136
  toolName: opts.toolName,
70
137
  args: {
71
- result: evidence.result?.slice(0, 2_000),
138
+ result: resultSample?.text,
72
139
  toolSteps: evidence.steps,
73
140
  editedFiles: evidence.edits,
74
- ...(evidence.partialText ? { partialFindings: evidence.partialText.slice(0, 2_000) } : {}),
141
+ ...(partialSample ? { partialFindings: partialSample.text } : {}),
142
+ ...(coverage ? { reviewCoverage: coverage } : {}),
75
143
  },
76
144
  toolCallId: opts.toolCallId,
77
145
  },
@@ -437,7 +505,7 @@ export function createSubagentResume(deps) {
437
505
  entry.cycleSeq += 1;
438
506
  const resumeSpec = {
439
507
  ...entry.specSnapshot,
440
- objective: createResumePrompt(marker, content),
508
+ objective: createResumePrompt(marker, content, deps.peerOrigin !== undefined ? "peer" : "operator"),
441
509
  sessionId: entry.childSessionId,
442
510
  requireExistingSession: true,
443
511
  ...(deps.currentOnQuestion !== undefined ? { onQuestion: deps.currentOnQuestion } : {}),
@@ -534,7 +602,10 @@ export function createSubagentResume(deps) {
534
602
  },
535
603
  }
536
604
  : {}),
537
- ...(deps.currentParentNotify !== undefined ? { parentNotify: deps.currentParentNotify } : {}),
605
+ ...(deps.currentParentNotify !== undefined
606
+ ? { parentNotify: deps.currentParentNotify, parentPeerRef: deps.currentParentPeerRef }
607
+ : {}),
608
+ peerInboundChainRef: createPeerInboundChainRef(deps.peerOrigin?.hopChain),
538
609
  ...(reviveCycle !== undefined
539
610
  ? {
540
611
  onNotifyInjectorReady: (inject) => {
@@ -1499,6 +1570,13 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1499
1570
  ...(treeScope !== undefined ? { registryScope: treeScope } : {}),
1500
1571
  ...(ctx.inheritedGateForChildren ? { inheritedGate: ctx.inheritedGateForChildren() } : {}),
1501
1572
  ownOrgAdmissionRef: { current: reviveClaim !== undefined ? readDurableOrgAdmission(reviveClaim.row) : undefined },
1573
+ peerSelfRef: (() => {
1574
+ const ref = createPeerSelfRef(treeScope ?? "default");
1575
+ if (reviveClaim !== undefined)
1576
+ ref.addAxis("h", reviveClaim.row.handle);
1577
+ return ref;
1578
+ })(),
1579
+ peerInboundChainRef: createPeerInboundChainRef(reviveClaim?.peerSeed?.hopChain),
1502
1580
  ...(childDefaultPersona !== undefined ? { defaultSystemPrompt: childDefaultPersona } : {}),
1503
1581
  isDelegatedChild: true,
1504
1582
  ...(ctx.interactionPosture !== undefined ? { parentInteractionPosture: ctx.interactionPosture } : {}),
@@ -1520,7 +1598,12 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1520
1598
  }),
1521
1599
  ...(ctx.centerArtifactDigest !== undefined ? { parentCenterArtifactDigest: ctx.centerArtifactDigest } : {}),
1522
1600
  ...(ctx.centerSourceRevision !== undefined ? { parentCenterSourceRevision: ctx.centerSourceRevision } : {}),
1523
- ...(ctx.onTaskNotification !== undefined ? { parentNotify: ctx.onTaskNotification } : {}),
1601
+ ...(ctx.onTaskNotification !== undefined
1602
+ ? {
1603
+ parentNotify: ctx.onTaskNotification,
1604
+ ...(ctx.peerSelfRef !== undefined ? { parentPeerRef: ctx.peerSelfRef } : {}),
1605
+ }
1606
+ : {}),
1524
1607
  ...(ctx.subagentRetain !== undefined ? { parentRetainLedger: ctx.subagentRetain } : {}),
1525
1608
  onForwardEvent: recordingForward,
1526
1609
  ...(worktreeDir !== undefined
@@ -1805,6 +1888,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1805
1888
  const wt = await finishWorktree();
1806
1889
  return { isError: true, content: `Sub-agent not started in background: ${e instanceof Error ? e.message : String(e)}${wt ? `\n${wt}` : ""}`, details: { error: "register_failed" } };
1807
1890
  }
1891
+ childInternals.peerSelfRef?.addAxis("h", taskId);
1808
1892
  bg.registry.bindBackgroundAgentSession(taskId, forkedId);
1809
1893
  const forkBgHangAt = Date.now();
1810
1894
  bg.registry.attachAgentTerminalNotifier(taskId, () => {
@@ -2255,6 +2339,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2255
2339
  const wt = await finishWorktree();
2256
2340
  return { isError: true, content: `Sub-agent not started in background: ${e instanceof Error ? e.message : String(e)}${wt ? `\n${wt}` : ""}`, details: { error: "register_failed" } };
2257
2341
  }
2342
+ childInternals.peerSelfRef?.addAxis("h", taskId);
2258
2343
  if (agentName !== undefined) {
2259
2344
  recordRosterSpawn(ctx.roster, { name: agentName, agentId: taskId, toolUseId: ctx.toolCallId, owner: bgOwner, scope: bgScope, ...((typeof childModel === "string" ? resolveModelDisplayLabel(childModel) : childModel?.id) !== undefined ? { model: typeof childModel === "string" ? childModel : childModel?.id } : {}), ...(reviveRow !== undefined ? ((reviveRow.rootSessionId ?? reviveRow.parentSessionId) !== undefined ? { rootSessionId: reviveRow.rootSessionId ?? reviveRow.parentSessionId } : {}) : (ctx.rootSessionId ?? ctx.sessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? ctx.sessionId } : {}), ...(sessionScopedBg ? { sessionScoped: true } : {}), createdAt: reviveRow?.spawnedAt ?? Date.now() }, (err) => opts.onObserverError?.(err, { site: "roster.recordSpawn" }));
2260
2345
  }
@@ -174,7 +174,12 @@ export function createAskUserQuestionTool(onQuestion, source, opts) {
174
174
  description: "Ask the user a structured multiple-choice question when you are genuinely stuck or a decision is " +
175
175
  "the user's to make (which approach, which of these). Provide 1-4 questions, each with 2-4 options " +
176
176
  "(label + description). Use sparingly — not for anything you can determine yourself from the code or " +
177
- "the task. If no human is available you will be told to proceed with your best judgment.\n" +
177
+ "the task. " +
178
+ (onQuestion !== undefined && opts?.posture === "interactive" && opts.interactiveFallback !== true
179
+ ? "If no human can be reached this call FAILS with a coded error — do not plan around being told to " +
180
+ "proceed with your best judgment."
181
+ : "If no human is available you will be told to proceed with your best judgment.") +
182
+ "\n" +
178
183
  "\n" +
179
184
  "Usage notes:\n" +
180
185
  '- Users will always be able to select "Other" to provide custom text input\n' +
@@ -251,6 +256,16 @@ export function createAskUserQuestionTool(onQuestion, source, opts) {
251
256
  details: { type: "ask-question", questionId: ctx.toolCallId, code: "question.human_unavailable", reason: "declined_unavailable" },
252
257
  };
253
258
  }
259
+ if (opts?.posture === "interactive" && opts.interactiveFallback !== true) {
260
+ return {
261
+ content: `Error (AskUserQuestion): the wired human channel reported that nobody was reachable for this ` +
262
+ `question. This run declared interaction posture "interactive", so the engine will not silently ` +
263
+ `self-answer; re-deliver the question once a human is reachable (or opt into the synthetic ` +
264
+ `continuation explicitly via interactiveQuestionFallback).`,
265
+ isError: true,
266
+ details: { type: "ask-question", questionId: ctx.toolCallId, code: "question.human_unavailable", reason: "declined_unavailable" },
267
+ };
268
+ }
254
269
  discloseSyntheticContinuation(ctx.toolCallId, "declined_unavailable");
255
270
  return {
256
271
  content: NO_HUMAN,