@sema-agent/core 5.55.0 → 5.57.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 (81) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/dist/agents/send-message-tool.d.ts +11 -0
  3. package/dist/agents/send-message-tool.js +81 -13
  4. package/dist/agents/subagent.js +250 -89
  5. package/dist/agents/team.d.ts +10 -1
  6. package/dist/agents/team.js +1 -0
  7. package/dist/brain/anthropic.js +15 -5
  8. package/dist/brain/circuit-breaker.js +2 -1
  9. package/dist/brain/degrading.js +4 -1
  10. package/dist/brain/failover.js +16 -1
  11. package/dist/brain/open-responses.js +15 -5
  12. package/dist/brain/openai.js +16 -5
  13. package/dist/brain/request-params.d.ts +30 -27
  14. package/dist/brain/request-params.js +1 -7
  15. package/dist/brain/route-adjudicator.d.ts +190 -0
  16. package/dist/brain/route-adjudicator.js +189 -0
  17. package/dist/brain/route-conformance.d.ts +55 -0
  18. package/dist/brain/route-conformance.js +136 -0
  19. package/dist/brain/routing.js +8 -3
  20. package/dist/core/auto-compaction.d.ts +17 -4
  21. package/dist/core/auto-compaction.js +3 -0
  22. package/dist/core/context-edit.d.ts +55 -6
  23. package/dist/core/context-edit.js +12 -1
  24. package/dist/core/hooks.d.ts +293 -11
  25. package/dist/core/hooks.js +158 -11
  26. package/dist/core/human-input-projection.d.ts +20 -2
  27. package/dist/core/human-input-projection.js +9 -0
  28. package/dist/core/mcp.js +4 -4
  29. package/dist/core/memory-engine/engine.d.ts +15 -5
  30. package/dist/core/memory-engine/engine.js +3 -1
  31. package/dist/core/permission-rule-consent.d.ts +45 -0
  32. package/dist/core/permission-rule-consent.js +40 -11
  33. package/dist/core/permission-rule-model.d.ts +110 -75
  34. package/dist/core/permission-rule-model.js +61 -28
  35. package/dist/core/permission-rules.d.ts +23 -15
  36. package/dist/core/permission-rules.js +40 -31
  37. package/dist/core/runner/prepare-task.d.ts +8 -0
  38. package/dist/core/runner/prepare-task.js +66 -26
  39. package/dist/core/runner/runtask.d.ts +4 -1
  40. package/dist/core/runner/runtask.js +206 -21
  41. package/dist/core/runner/session-rule-policy.js +5 -5
  42. package/dist/core/scheduler.d.ts +5 -0
  43. package/dist/core/session-reconcile.d.ts +32 -0
  44. package/dist/core/session-reconcile.js +15 -0
  45. package/dist/core/side-query.d.ts +12 -5
  46. package/dist/core/task-notification.d.ts +34 -7
  47. package/dist/core/task-notification.js +11 -1
  48. package/dist/core/task-registry-agent.d.ts +20 -3
  49. package/dist/core/task-registry-agent.js +31 -2
  50. package/dist/core/tool-policy.d.ts +14 -9
  51. package/dist/core/tool-policy.js +27 -22
  52. package/dist/core/types.d.ts +69 -11
  53. package/dist/core/untrusted-text.js +8 -0
  54. package/dist/engine/compaction/compaction.d.ts +77 -7
  55. package/dist/engine/compaction/compaction.js +98 -9
  56. package/dist/engine/compaction/utils.d.ts +4 -0
  57. package/dist/engine/compaction/utils.js +6 -0
  58. package/dist/engine/harness/agent-harness.d.ts +84 -0
  59. package/dist/engine/harness/agent-harness.js +114 -13
  60. package/dist/engine/harness/messages.d.ts +4 -2
  61. package/dist/engine/harness/messages.js +7 -2
  62. package/dist/engine/harness/types.d.ts +16 -6
  63. package/dist/engine/llm/types.d.ts +65 -0
  64. package/dist/engine/loop/types.d.ts +7 -0
  65. package/dist/engine/session/import-validate.js +10 -0
  66. package/dist/engine/session/session.js +2 -2
  67. package/dist/index.d.ts +4 -1
  68. package/dist/index.js +3 -1
  69. package/dist/internal/llm.d.ts +1 -1
  70. package/dist/orchestration/run-spec.js +8 -1
  71. package/dist/prompts/default.d.ts +12 -6
  72. package/dist/prompts/default.js +2 -0
  73. package/dist/scenarios/scenario-registry.d.ts +5 -1
  74. package/dist/scenarios/scenario-registry.js +4 -2
  75. package/dist/tools/fs/index.js +8 -1
  76. package/dist/tools/scheduler-tools.js +28 -6
  77. package/dist/tools/web.d.ts +15 -0
  78. package/dist/tools/web.js +8 -2
  79. package/dist/tools/worktree.js +2 -2
  80. package/package.json +1 -1
  81. package/test/export-surface.snapshot.json +19 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,145 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.57.0 — 2026-08-23
4
+
5
+ ### Added
6
+ - **key×URL pairing adjudicator** (#309 cross-repo case, [5060]-[5063] consensus): `adjudicateModelRoute`
7
+ is the single source of the credential-pairing law. Per-model credential = paired by construction;
8
+ the deployment-config credential passes only on the deployment's own declared root; absent credential
9
+ is legal keyless except for an entry declaring its own off-root URL. Codes `route.credential_mismatch`
10
+ / `route.credential_missing` (refusal detail carries entry id + both URL halves + a one-line fixHint,
11
+ fail-closed by ruling). The three first-party brains gate inside buildRequest (refusal terminates
12
+ before any fetch) and expose `Brain.adjudicateRoute`; routing/failover/degrading/circuit-breaker
13
+ re-judge per leg (failover serves from the paired leg; a broken degrading hop is skipped). Two-leg
14
+ disposition: a NAMED model surfaces the refusal; a DERIVED model (compaction summarize, auto-mode
15
+ classifier, suggestions) pre-flights and falls back to the primary with a `route.fallback_to_primary`
16
+ notice; `route.base_url_changed_key_unchanged` mints at swapModels. `ROUTE_ADJUDICATION_CONFORMANCE_CORPUS`
17
+ (16 vectors) exported as the cross-codebase anchor. **Narrowings (each with a fixHint)**: declared-root
18
+ deployments' off-root entries now refuse instead of silently borrowing the gateway credential; an
19
+ entry-declared auth header now wins over the deployment key. Preserved byte-identical: unpinned
20
+ quick-start, keyless single-gateway, header-only boot token, gateway dual-auth.
21
+ - **Per-model auth seats for WebFetch summarizer and team surfaces** (#417): `getApiKeyAndHeaders`
22
+ on `WebFetchSummarizerOptions` / `TeamDiscussionOptions` / `runScenario` (absent seat = byte-identical).
23
+ - **Compound-prefix permission rules** (#364 case B, ruled; registered divergence): `Bash(cd /tmp && adb pull:*)`
24
+ — leading segments byte-exact, only the final segment takes arguments. The validator accepts the
25
+ chain form (a `*` in any other position still refuses); the matcher admits on segment-count equality
26
+ + byte-prefix at a word boundary (a single-command prefix still never admits a compound; the segment
27
+ deny/ask fence stays strictly ahead of the allow lane); the suggester mints the compound-prefix
28
+ sibling at index 1 when the final segment fits the same grammar as the simple seat; the card-edit
29
+ face accepts the respelling; org tighten gains the same grammar (a compound-prefix deny is enforceable
30
+ instead of bricking its snapshot). **Widenings**: validator accepts the new form; eligible compounds
31
+ offer 2 seats; card edit + import accept it; org snapshots carrying it are enforceable. **Mixed-fleet
32
+ note**: older engines quarantine compound-prefix rows (bytes preserved, disclosed) — do not publish
33
+ compound-prefix org rules before the fleet is level.
34
+ - **Generic prefix grammar for rule suggestions** (#364 R1, CC 2.1.223 iNo corpus): the prefix arm
35
+ uses the corpus rule `^[a-z][a-z0-9]*(-[a-z0-9]+)*$` with the interpreter/wrapper blacklist; the
36
+ 125-line lexicon demotes to a bonus for multi-word deepening; ASCII-closed head-name screen.
37
+ - **`precheckEditedRuleText(text, command)`** (#428, server pickup): the card-edit face's three text
38
+ gates (spelling normalization → grammar → coverage bound) extracted to one shared body and exported
39
+ (`EditedRuleTextPrecheck`). `ok` means SUBMITTABLE (record-level gates stay with `confirmRuleApproval`);
40
+ `canonicalRule` may differ from the submitted bytes; refusal words match `edit_rejected.detail`
41
+ verbatim (`code` rides validator refusals only). The entry screens unrenderable command bytes loudly
42
+ (`config.invalid_argument`).
43
+ - **ScheduleWakeup honesty family** (#424 A-1/A-4): the wakeup intent carries `lifetime:"session"`
44
+ where the backend vouched for the reap contract; an undeclared backend keeps the historic durable
45
+ intent with receipt + description disclosure; `reason` rides `details.reason`. An incomplete
46
+ replace (stale wakeup possibly still armed) is disclosed on the receipt.
47
+ - **SendMessage completion promises follow mount truth** (#424 A-6): `notificationWired?` / `oneShot?`
48
+ seats (defaults preserve wired behavior byte-identical); the unwired arm teaches retrieval, the
49
+ one-shot arm instructs blocking retrieval now; ten sites across two wordings guarded.
50
+
51
+ ### Fixed
52
+ - **Derived-route pre-flight can never sink the main task** (pre-ship rescan P1): the judge call and
53
+ the whole seat (including URL normalization of garbage catalog values) now abstain on throw instead
54
+ of failing task preparation and leaking the acquired session view; both derived seats share the
55
+ guarded helper.
56
+ - **Route URL comparison is wire-equivalent** (rescan): scheme/host case-fold + default-port elision
57
+ (path half stays byte-ordered; parse failure falls back to raw) — case-only respellings of the same
58
+ endpoint no longer refuse as `credential_mismatch`.
59
+ - **MCP revocation copy tells the standing truth at all four outlets** (rescan; #424 A-5 completed):
60
+ the ListMcpResources arm joins the other three ("stays on this run's roster; every call refused
61
+ the same way"), with an extinction tripwire for the old sentence.
62
+ - **AgentHarness auth carriers refuse loudly at the two host seats** (rescan hardening): a
63
+ deployment-wide auth header in `streamOptions.headers` is refused with a migration hint instead of
64
+ silently riding beside per-model credentials; `stripAuthHeaders` retired (zero callers).
65
+ - **ExitWorktree parameter texts describe this tool** (#424 A-8): detached worktrees have no branch;
66
+ a changed tree is KEPT (not refused) — CC originals and reasoning recorded at the schema.
67
+ - **Read-only memory notice restores CC's recall-framing paragraph** (#424 A-9, byte-compared).
68
+ - **MCP/scheduler small truths** (rescan): the wakeup mount note is led by the `supportsSessionWakeup:false`
69
+ arm; an explicitly-declared `supportsSessionLifetime:false` backend behaves like an undeclared one
70
+ (seam defines the two spellings as one state — the refusal arm from an unshipped interim build rolled back).
71
+
72
+ ### Bench (not part of the package surface)
73
+ - LLM distillation arm for the memory benchmark (three-clause instruction arc: retained 20.8%→100%,
74
+ convergence restored; distillation quality flat with the hand-authored ceiling on this corpus's
75
+ measurable axes); live provider key no longer travels to the loopback observation plane.
76
+
77
+ ## 5.56.0 — 2026-08-23
78
+
79
+ ### Fixed
80
+ - **A2A covering rules join the mcp form** (#410, ruled): `a2a__peer` / `a2a__peer__*` deny, ask
81
+ and allow now fire in all five name-keyed lanes (rule DSL, allow/deny policy, approval policy,
82
+ frozen projection replay, persisted session rules) via the protocol-table-driven
83
+ `namespacedRuleNameCovers`. **Narrowings**: a frozen projection replayed under 5.56 honors
84
+ covering spellings it previously read as inert (heaviest); covering-name parenthesised rules
85
+ refuse (`unsupported.covering_paren`, additive union member); empty-peer spellings refuse at
86
+ construction. **Widening / false-refusal repair**: real minted names with separator-bearing tool
87
+ segments (`mcp__srv____foo` and twins) that previously THREW at construction now compile as
88
+ exact entries (the name-set screen judges the peer boundary only).
89
+ - **Delegation entry caps cover every local spawn leg** (#385, anchor campaign): sync spawn,
90
+ sync fork and background fork now pass the same concurrency/cumulative gate as background
91
+ spawns (one pool, four admit sites; in-flight reservations returned on settle). Sync legs mint
92
+ `delegation.concurrency_cap` / `delegation.session_cap` under the `Sub-agent not started: `
93
+ prefix (new code+prefix combination); counted nouns drop the word "background";
94
+ `AgentDefinition.background: true` is honored (previously a dead field); no-principal mounts
95
+ fold to the engine default pool instead of skipping the gate.
96
+ - **Every deployment hook seat is time-bounded** (#388, anchor campaign): the ten `Hooks` seats
97
+ ride one bounded family (default 600s; `Hooks.timeoutMs` knob, bad values refused loudly).
98
+ Decision seats fail closed on expiry (preToolUse → deny + permissionDenied(source "hook");
99
+ userPromptSubmit → block; stop → run ends); observation seats discard and shrink to a 1s grace
100
+ after task abort; hook contexts carry `signal` and live tracked `cwd`. Pre-ship rescan closures:
101
+ the runSpec hook mirror now carries `timeoutMs` (a deployment's bound was silently replaced by
102
+ the default on composed runs); stopFailure reads the deadline rather than a constant grace; the
103
+ post-abort grace caps remaining time instead of restarting the clock.
104
+ - **interrupt() keeps accepted user input; an interrupt leaves CC's transcript marker** (#389,
105
+ anchor campaign): steer queues survive `interrupt()` (one-way abort latch + in-flight slot);
106
+ a durable park migrates queued steers onto the checkpoint (idempotent CAS verb) and the
107
+ undrained account announces only the remainder; an explicit interrupt that won the abort mints
108
+ CC's verbatim `[Request interrupted by user]` marker (double-gated); unknown
109
+ `SystemInjectionPriority` values refuse and the unimplemented `"now"` ladder is disclosed once
110
+ per run (`task.injection_priority_unimplemented`); the untrusted caller steer lane gains CC's
111
+ mid-turn delivery frame.
112
+ - **A SendMessage name that changed hands refuses instead of silently redirecting** (CC's
113
+ `send_message_pin_guard` rebound arm): "Nothing was sent." plus both ways forward; a precise
114
+ task_id is never refused and re-pins; the tool description states the refusal.
115
+ - **A legacy-decided parked row converges** (#400, test P0-KPI): the live reconcile's resolved
116
+ skip now means "a claim is in flight" (read from the store row) — a checkpoint decided by the
117
+ task-level legacy lane settles the row instead of leaving a process-lifetime zombie; the
118
+ decision is re-read immediately before the verdict so a reopen CAS is never judged dead.
119
+ - **Compaction PTL retries disclose what they dropped** (#407, test P0-KPI):
120
+ `CompactionDetails.unsummarizedMessages` accumulates the true uncovered count across split
121
+ legs, with a shell-copy clause and an import-gate check. The summary prompt carries CC's
122
+ `<example>` skeleton and a compliant `<summary>` envelope is unwrapped in the persisted body
123
+ (pre-ship rescan closure: a fence-only EMPTY response is refused loudly, same class and wording
124
+ as the bare-empty arm — it could previously persist a footer-only summary).
125
+ `keepRecentToolResults` floors at 1 and refuses NaN/negative loudly.
126
+ - **#384 (keepRecentTokens default) closed as REFUTED, with evidence**: the anchor-campaign claim
127
+ that the 0 default meant "no kept tail" was disproved on a real Runner — `findCutPoint` has a
128
+ structural floor, the kept tail is never empty, and the pinned behavior matches CC's posture
129
+ via the other knob. The default is unchanged; the floor is now pinned
130
+ (test/compaction-keep-tail-floor.test.ts) and four stale anchor comments were corrected.
131
+
132
+ ### Notes
133
+ - Anchor-campaign satellite dispositions ride along (A3/A4/A8/A14 candidate tables in the
134
+ respective cars' receipts): truthfulness rewrites, registry entries, and the
135
+ PermissionDenied trigger-surface divergence registered.
136
+ - Erratum owed to sibling repos (also stated in src/core/hooks.ts): "CC fails open on hook
137
+ faults except exit 2" is true only of the command(shell) hook channel; CC's in-process
138
+ callback channel is fail-closed with dedicated copy — cited as a fail-open precedent it is
139
+ the wrong channel.
140
+ - Residuals filed, not shipped silently: #417-#423 (auth seats, Agent(<type>) governance,
141
+ lifecycle legs, postToolUse payload asymmetry, reopen fence, classic-arm Trust-but-verify).
142
+
3
143
  ## 5.55.0 — 2026-08-23
4
144
 
5
145
  ### BREAKING
@@ -33,6 +33,17 @@ export interface SendMessageToolOptions {
33
33
  notify?: (n: TaskNotificationPayload, opts?: {
34
34
  priority?: "now" | "next" | "later";
35
35
  }) => void;
36
+ /** Whether a completion notification really reaches the sender — drives every "you will be notified"
37
+ * sentence on this face (description + four receipts), mirroring TaskOutput's `notificationWired`.
38
+ * Default = `notify !== undefined`, which is the truth on both the first-party mount and a direct
39
+ * mount that wires its own sink here. Pass `true` only when the deployment announces a finished
40
+ * agent through a channel of its own (this tool then keeps the wired wording); `false` states the
41
+ * degraded truth explicitly. */
42
+ notificationWired?: boolean;
43
+ /** RB-220 mirror of {@link import("../core/types.js").TaskSpec.oneShot} (the Runner mount fills it):
44
+ * this run's process exits when the turn ends, so no completion notification can ever land — checked
45
+ * AHEAD of {@link notificationWired}, since no amount of wiring makes a later turn exist. */
46
+ oneShot?: boolean;
36
47
  /** Steer-handle sink: the revived run re-emits a FRESH handle (design/122 risk-table contract). */
37
48
  sink?: (handle: SubagentSteerHandle) => void;
38
49
  /**
@@ -5,6 +5,7 @@ import { announceTranscriptIntegrityGapOnce, canAccessAgentRecord, clearRevivedR
5
5
  import { uuidv7 } from "../internal/harness.js";
6
6
  import { MAILBOX_TOMBSTONED_RECIPIENT_CODE } from "../core/mailbox-store.js";
7
7
  import { escapeAttributeValue, escapeEnvelopeTag, isObserverTaskId, OBSERVER_SENDMESSAGE_SENDER_REFUSAL, OBSERVER_SENDMESSAGE_TARGET_REFUSAL, } from "./observer.js";
8
+ import { inlineUntrusted } from "../core/untrusted-text.js";
8
9
  import { SUBAGENT_RESUME_CAP, SubagentRetainLedger, getSessionRetainLedger } from "./retain-ledger.js";
9
10
  import { createSubagentResume } from "./subagent.js";
10
11
  import { appendHopToken, judgePeerAdmission, peerAxisToken, resolvePeerAdmissionConfig, PEER_MESSAGE_NOTICE, } from "./peer-admission.js";
@@ -45,6 +46,50 @@ function targetLaneKey(scope, targetId) {
45
46
  const OPERATOR_CONTINUATION_CTX = Symbol("sema.operator_continuation");
46
47
  export function createSendMessageTool(opts) {
47
48
  const tier3Capable = opts.agentStore !== undefined && opts.mailbox !== undefined && opts.reviveSpawn !== undefined;
49
+ const completionMode = opts.oneShot === true ? "one_shot" : (opts.notificationWired ?? opts.notify !== undefined) ? "notified" : "silent";
50
+ const notifyWired = completionMode === "notified";
51
+ const NO_COMPLETION_NOTICE = completionMode === "one_shot"
52
+ ? "this is a ONE-SHOT submission — there is no later turn for a completion notification to land in, so retrieve its result NOW with TaskOutput(task_id, block: true) rather than ending your turn"
53
+ : "its completion is NOT announced on this mount — retrieve its status and result with TaskOutput(task_id) where mounted";
54
+ const AWAIT_COMPLETION = notifyWired
55
+ ? "Wait for its completion notification"
56
+ : completionMode === "one_shot"
57
+ ? "Wait for it NOW with TaskOutput(task_id, block: true) — this one-shot submission ends with this turn"
58
+ : "Check for its completion with TaskOutput(task_id) where mounted";
59
+ const awaitCompletion = notifyWired
60
+ ? "wait for its completion notification"
61
+ : completionMode === "one_shot"
62
+ ? "wait for it NOW with TaskOutput(task_id, block: true) — this one-shot submission ends with this turn"
63
+ : "check for its completion with TaskOutput(task_id) where mounted";
64
+ const sendMessagePins = new Map();
65
+ const pinGuard = (targetId, targetName, rung, to) => {
66
+ if (rung === "other")
67
+ return undefined;
68
+ const pinKey = normalizeAgentName(targetName ?? "");
69
+ if (pinKey === "")
70
+ return undefined;
71
+ const pinned = sendMessagePins.get(pinKey);
72
+ if (pinned === undefined || pinned.id === targetId) {
73
+ sendMessagePins.set(pinKey, { id: targetId, name: targetName ?? to });
74
+ return undefined;
75
+ }
76
+ if (rung === "ref") {
77
+ sendMessagePins.set(pinKey, { id: targetId, name: targetName ?? to });
78
+ return undefined;
79
+ }
80
+ if (targetName !== undefined && normalizeAgentName(to) === pinKey && normalizeAgentName(pinned.name) !== pinKey) {
81
+ return undefined;
82
+ }
83
+ return {
84
+ content: `Message not sent: "${inlineUntrusted(to, 120)}" now resolves to a different agent than it did earlier in ` +
85
+ `this run — this name previously resolved to ${pinned.id.slice(0, 64)}, which it no longer reaches. Nothing was sent.\n` +
86
+ `It now resolves to ${targetId.slice(0, 64)}. To message that agent, re-send with its task_id: ` +
87
+ `{"to": "${targetId.slice(0, 64)}", …}. To reach the earlier agent, use its task_id ` +
88
+ `${pinned.id.slice(0, 64)} from its spawn result.`,
89
+ details: { error: "rebound", to, taskId: targetId, previousTaskId: pinned.id },
90
+ isError: true,
91
+ };
92
+ };
48
93
  return defineTool({
49
94
  name: SEND_MESSAGE_TOOL_NAME,
50
95
  contract: { contractId: "core.send_message@1", implementationRevision: "1" },
@@ -54,9 +99,13 @@ export function createSendMessageTool(opts) {
54
99
  `'to' is the agent's name (set at spawn via Agent({name})) or its task_id (a…). A RUNNING agent receives the ` +
55
100
  `message at its next turn (queued — never interrupts its current work); a FINISHED agent resumes as a new ` +
56
101
  `background run with its full prior conversation preserved, so don't re-explain what it already knows. Names ` +
57
- `keep working after an agent completes; when a newer agent took the name, the newest wins use the task_id to ` +
58
- `address an older one. You will be notified automatically when it completes prefer ending your turn; do not ` +
59
- `poll. ` +
102
+ `keep working after an agent completes; but if a name you already messaged is later taken by a NEWER agent, the ` +
103
+ `send is refused rather than silently redirected re-send with the task_id of whichever agent you meant. ` +
104
+ (notifyWired
105
+ ? `You will be notified automatically when it completes — prefer ending your turn; do not poll. `
106
+ : completionMode === "one_shot"
107
+ ? `This is a ONE-SHOT submission: there is no later turn for a completion notification to land in, so do NOT end your turn expecting one — wait actively with TaskOutput(task_id, block: true) when you need a continued agent's result. `
108
+ : `A finished agent is NOT announced on this mount — check on it with TaskOutput(task_id) where mounted rather than ending your turn to wait for a notification that never comes. `) +
60
109
  (tier3Capable
61
110
  ? `Continuing a finished agent works for agents with a durable record — by name or task_id, even across restarts — and for runs that retain sub-agent sessions; when neither covers it you get an honest error and should launch a new agent with the needed context instead.`
62
111
  : `Continuing a finished agent requires the run to retain sub-agent sessions; when the session was not ` +
@@ -224,7 +273,7 @@ export function createSendMessageTool(opts) {
224
273
  if (row.status === "running") {
225
274
  if (row.writerId === opts.registry.writerId) {
226
275
  return {
227
- content: `Message not sent: ${whoT3} is currently running in this process but its mid-run delivery channel is not reachable from here. Wait for its completion notification, then send again to continue it.`,
276
+ content: `Message not sent: ${whoT3} is currently running in this process but its mid-run delivery channel is not reachable from here. ${AWAIT_COMPLETION}, then send again to continue it.`,
228
277
  details: { error: "still_running", to },
229
278
  isError: true,
230
279
  };
@@ -237,7 +286,7 @@ export function createSendMessageTool(opts) {
237
286
  };
238
287
  }
239
288
  return {
240
- content: `Message not sent: ${whoT3} is running on another host — this process has no delivery channel to it. Wait for its completion notification, then send again to continue it.`,
289
+ content: `Message not sent: ${whoT3} is running on another host — this process has no delivery channel to it. ${AWAIT_COMPLETION}, then send again to continue it.`,
241
290
  details: { error: "running_elsewhere", to },
242
291
  isError: true,
243
292
  };
@@ -470,7 +519,9 @@ export function createSendMessageTool(opts) {
470
519
  const priorCount = lease.messages.length - 1;
471
520
  return {
472
521
  content: `Message sent — ${whoT3} was revived in the background with its prior context intact${priorCount > 0 ? ` (${priorCount} earlier pending message(s) delivered with it)` : ""}. ` +
473
- `You will be notified automatically when it completes. Continue with other work — do not poll.`,
522
+ (notifyWired
523
+ ? `You will be notified automatically when it completes. Continue with other work — do not poll.`
524
+ : `${NO_COMPLETION_NOTICE.charAt(0).toUpperCase()}${NO_COMPLETION_NOTICE.slice(1)}.`),
474
525
  details: { type: "send-message", status: "revived", to, task_id: handle, seq: nextSeq, ...(priorCount > 0 ? { priorMessages: priorCount } : {}) },
475
526
  };
476
527
  }
@@ -523,6 +574,11 @@ export function createSendMessageTool(opts) {
523
574
  }
524
575
  }
525
576
  if (rosterHit !== undefined) {
577
+ {
578
+ const guard = pinGuard(rosterHit.agentId, to, "name", to);
579
+ if (guard !== undefined)
580
+ return guard;
581
+ }
526
582
  const revived = await tier3Revive(rosterHit.agentId);
527
583
  if (revived !== undefined)
528
584
  return revived;
@@ -563,6 +619,16 @@ export function createSendMessageTool(opts) {
563
619
  if (row.type !== "background_agent") {
564
620
  return { content: `Message not sent: ${targetId} is a ${row.type} task, not a background agent.`, details: { error: "wrong_type", to }, isError: true };
565
621
  }
622
+ {
623
+ const rung = idRow !== undefined
624
+ ? "ref"
625
+ : row.name !== undefined && (row.name === to || normalizeAgentName(row.name) === normalizeAgentName(to))
626
+ ? "name"
627
+ : "other";
628
+ const guard = pinGuard(targetId, row.name, rung, to);
629
+ if (guard !== undefined)
630
+ return guard;
631
+ }
566
632
  if (row.status === "running" || row.status === "pending") {
567
633
  return await withTargetLane(targetLaneKey(access.scope, targetId), async () => {
568
634
  const liveRecipientKey = peerAxisToken(resolvedAccess.scope, "h", targetId);
@@ -582,10 +648,10 @@ export function createSendMessageTool(opts) {
582
648
  }, { priority: "next" });
583
649
  if (delivered.ok) {
584
650
  const receiptText = delivered.disposition === "parked"
585
- ? `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.`
651
+ ? `Message parked for ${who}: the agent finished before reading it — it will be delivered when the agent is next continued. ${notifyWired ? "You will be notified of its completion; continue with other work." : `Note: ${NO_COMPLETION_NOTICE}.`}`
586
652
  : delivered.disposition === "pending"
587
- ? `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}`
588
- : `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}`;
653
+ ? `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. ${notifyWired ? "You will be notified of the agent's completion either way" : `Note: ${NO_COMPLETION_NOTICE}`}; resend then if it went unanswered. ${DEDUP_RETRY_NOTE}`
654
+ : `Message queued for delivery to ${who} at its next turn. If it finishes before reading it, the message may not survive — ${notifyWired ? "you will be notified of its completion either way" : NO_COMPLETION_NOTICE}; resend then if it went unanswered. ${notifyWired ? "Continue with other work; do not poll. " : ""}${DEDUP_RETRY_NOTE}`;
589
655
  return {
590
656
  content: receiptText,
591
657
  details: { type: "send-message", status: "delivered_running", disposition: delivered.disposition, to, task_id: targetId, summary },
@@ -593,7 +659,7 @@ export function createSendMessageTool(opts) {
593
659
  }
594
660
  if (delivered.reason === "no_channel") {
595
661
  return {
596
- 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}`,
662
+ content: `Message not sent: ${who} is still running and this deployment has no mid-run delivery channel for it. ${AWAIT_COMPLETION}, then SendMessage to continue it. ${DEDUP_RETRY_NOTE}`,
597
663
  details: { error: "still_running", to },
598
664
  isError: true,
599
665
  };
@@ -659,7 +725,7 @@ export function createSendMessageTool(opts) {
659
725
  }
660
726
  if (ledger.get(resumeToolUseId)?.running === true) {
661
727
  return {
662
- content: `Message not sent: ${who} (or a prior follow-up to it) is still running — wait for its completion notification.`,
728
+ content: `Message not sent: ${who} (or a prior follow-up to it) is still running — ${awaitCompletion}.`,
663
729
  details: { error: "steering.still_running", to },
664
730
  isError: true,
665
731
  };
@@ -715,7 +781,9 @@ export function createSendMessageTool(opts) {
715
781
  const marker = await resume(`${fromPrefix}[${safeSummary}] ${safeMessage}`);
716
782
  return {
717
783
  content: `Message sent — ${who} resumed in the background with its prior context intact (correlation marker [${marker}]).\n` +
718
- `You will be notified automatically when it completes; its reply will carry [${marker}]. Continue with other work — do not poll.`,
784
+ (notifyWired
785
+ ? `You will be notified automatically when it completes; its reply will carry [${marker}]. Continue with other work — do not poll.`
786
+ : `Its reply will carry [${marker}], but ${NO_COMPLETION_NOTICE}.`),
719
787
  details: { type: "send-message", status: "resumed", to, task_id: targetId, marker, summary },
720
788
  };
721
789
  }
@@ -732,7 +800,7 @@ export function createSendMessageTool(opts) {
732
800
  ? `${who} reached its resume cap (${SUBAGENT_RESUME_CAP} follow-ups per agent) — relaunch a new agent instead.`
733
801
  : code === "steering.still_running"
734
802
  ?
735
- `${who} (or a prior follow-up to it) is still running — wait for its completion notification. ${DEDUP_RETRY_NOTE}`
803
+ `${who} (or a prior follow-up to it) is still running — ${awaitCompletion}. ${DEDUP_RETRY_NOTE}`
736
804
  : code === "resume.row_recycling"
737
805
  ?
738
806
  `${who}'s registry row is being adjudicated right now (a revival claim or a reap sweep holds it) — send again in a moment. ${DEDUP_RETRY_NOTE}`