@sema-agent/core 5.54.0 → 5.56.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 (85) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/dist/agents/cumulative-stats.d.ts +26 -0
  3. package/dist/agents/cumulative-stats.js +56 -0
  4. package/dist/agents/observer.d.ts +11 -7
  5. package/dist/agents/observer.js +2 -4
  6. package/dist/agents/send-message-tool.js +48 -2
  7. package/dist/agents/subagent.js +250 -89
  8. package/dist/agents/verify.d.ts +27 -3
  9. package/dist/agents/verify.js +7 -2
  10. package/dist/core/auto-compaction.d.ts +17 -4
  11. package/dist/core/auto-compaction.js +3 -0
  12. package/dist/core/context-edit.d.ts +55 -6
  13. package/dist/core/context-edit.js +12 -1
  14. package/dist/core/governance-codes.js +14 -0
  15. package/dist/core/hooks.d.ts +293 -11
  16. package/dist/core/hooks.js +159 -12
  17. package/dist/core/human-input-projection.d.ts +20 -2
  18. package/dist/core/human-input-projection.js +9 -0
  19. package/dist/core/lsp-diagnostics.d.ts +19 -17
  20. package/dist/core/lsp-diagnostics.js +11 -5
  21. package/dist/core/mcp.d.ts +46 -0
  22. package/dist/core/mcp.js +132 -6
  23. package/dist/core/memory-engine/consolidation.d.ts +378 -0
  24. package/dist/core/memory-engine/consolidation.js +342 -0
  25. package/dist/core/memory-engine/dual-root.js +3 -0
  26. package/dist/core/memory-engine/engine.d.ts +237 -4
  27. package/dist/core/memory-engine/engine.js +1111 -4
  28. package/dist/core/memory-engine/export-bundle.js +9 -0
  29. package/dist/core/memory-engine/file-backend.js +27 -1
  30. package/dist/core/memory-engine/frontmatter.d.ts +20 -1
  31. package/dist/core/memory-engine/frontmatter.js +111 -0
  32. package/dist/core/memory-engine/index.d.ts +4 -2
  33. package/dist/core/memory-engine/index.js +3 -1
  34. package/dist/core/memory-engine/memory-backend-contract.js +131 -0
  35. package/dist/core/memory-engine/sync-client.js +26 -0
  36. package/dist/core/memory-engine/tools.d.ts +9 -0
  37. package/dist/core/memory-engine/tools.js +57 -13
  38. package/dist/core/memory-engine/types.d.ts +99 -0
  39. package/dist/core/memory-recall.js +4 -3
  40. package/dist/core/memory.d.ts +33 -3
  41. package/dist/core/memory.js +6 -4
  42. package/dist/core/permission-rules.d.ts +30 -0
  43. package/dist/core/permission-rules.js +71 -8
  44. package/dist/core/reminder-disclosure.d.ts +29 -4
  45. package/dist/core/reminder-disclosure.js +60 -12
  46. package/dist/core/runner/prepare-memory.js +7 -2
  47. package/dist/core/runner/prepare-task.d.ts +39 -1
  48. package/dist/core/runner/prepare-task.js +63 -35
  49. package/dist/core/runner/runtask.d.ts +8 -1
  50. package/dist/core/runner/runtask.js +170 -31
  51. package/dist/core/runner/session-rule-policy.js +5 -3
  52. package/dist/core/runner/synthetic-tools.js +4 -2
  53. package/dist/core/runner/turn-attachments.d.ts +16 -6
  54. package/dist/core/runner/turn-attachments.js +34 -20
  55. package/dist/core/session-reconcile.d.ts +32 -0
  56. package/dist/core/session-reconcile.js +15 -0
  57. package/dist/core/task-notification.d.ts +34 -7
  58. package/dist/core/task-notification.js +11 -1
  59. package/dist/core/task-registry-agent.d.ts +20 -3
  60. package/dist/core/task-registry-agent.js +31 -2
  61. package/dist/core/tool-policy.d.ts +23 -0
  62. package/dist/core/tool-policy.js +29 -13
  63. package/dist/core/types.d.ts +126 -17
  64. package/dist/core/untrusted-egress.js +12 -2
  65. package/dist/core/untrusted-text.d.ts +189 -3
  66. package/dist/core/untrusted-text.js +424 -6
  67. package/dist/engine/compaction/compaction.d.ts +77 -7
  68. package/dist/engine/compaction/compaction.js +98 -9
  69. package/dist/engine/compaction/utils.d.ts +4 -0
  70. package/dist/engine/compaction/utils.js +6 -0
  71. package/dist/engine/harness/agent-harness.d.ts +84 -0
  72. package/dist/engine/harness/agent-harness.js +88 -12
  73. package/dist/engine/harness/messages.d.ts +4 -2
  74. package/dist/engine/harness/messages.js +7 -2
  75. package/dist/engine/harness/types.d.ts +11 -5
  76. package/dist/engine/loop/types.d.ts +14 -0
  77. package/dist/engine/session/import-validate.js +10 -0
  78. package/dist/engine/session/session.js +2 -2
  79. package/dist/index.d.ts +1 -1
  80. package/dist/index.js +1 -1
  81. package/dist/orchestration/run-spec.js +8 -1
  82. package/dist/prompts/default.d.ts +22 -6
  83. package/dist/tools/fs/index.d.ts +3 -1
  84. package/package.json +1 -1
  85. package/test/export-surface.snapshot.json +28 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,165 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.56.0 — 2026-08-23
4
+
5
+ ### Fixed
6
+ - **A2A covering rules join the mcp form** (#410, ruled): `a2a__peer` / `a2a__peer__*` deny, ask
7
+ and allow now fire in all five name-keyed lanes (rule DSL, allow/deny policy, approval policy,
8
+ frozen projection replay, persisted session rules) via the protocol-table-driven
9
+ `namespacedRuleNameCovers`. **Narrowings**: a frozen projection replayed under 5.56 honors
10
+ covering spellings it previously read as inert (heaviest); covering-name parenthesised rules
11
+ refuse (`unsupported.covering_paren`, additive union member); empty-peer spellings refuse at
12
+ construction. **Widening / false-refusal repair**: real minted names with separator-bearing tool
13
+ segments (`mcp__srv____foo` and twins) that previously THREW at construction now compile as
14
+ exact entries (the name-set screen judges the peer boundary only).
15
+ - **Delegation entry caps cover every local spawn leg** (#385, anchor campaign): sync spawn,
16
+ sync fork and background fork now pass the same concurrency/cumulative gate as background
17
+ spawns (one pool, four admit sites; in-flight reservations returned on settle). Sync legs mint
18
+ `delegation.concurrency_cap` / `delegation.session_cap` under the `Sub-agent not started: `
19
+ prefix (new code+prefix combination); counted nouns drop the word "background";
20
+ `AgentDefinition.background: true` is honored (previously a dead field); no-principal mounts
21
+ fold to the engine default pool instead of skipping the gate.
22
+ - **Every deployment hook seat is time-bounded** (#388, anchor campaign): the ten `Hooks` seats
23
+ ride one bounded family (default 600s; `Hooks.timeoutMs` knob, bad values refused loudly).
24
+ Decision seats fail closed on expiry (preToolUse → deny + permissionDenied(source "hook");
25
+ userPromptSubmit → block; stop → run ends); observation seats discard and shrink to a 1s grace
26
+ after task abort; hook contexts carry `signal` and live tracked `cwd`. Pre-ship rescan closures:
27
+ the runSpec hook mirror now carries `timeoutMs` (a deployment's bound was silently replaced by
28
+ the default on composed runs); stopFailure reads the deadline rather than a constant grace; the
29
+ post-abort grace caps remaining time instead of restarting the clock.
30
+ - **interrupt() keeps accepted user input; an interrupt leaves CC's transcript marker** (#389,
31
+ anchor campaign): steer queues survive `interrupt()` (one-way abort latch + in-flight slot);
32
+ a durable park migrates queued steers onto the checkpoint (idempotent CAS verb) and the
33
+ undrained account announces only the remainder; an explicit interrupt that won the abort mints
34
+ CC's verbatim `[Request interrupted by user]` marker (double-gated); unknown
35
+ `SystemInjectionPriority` values refuse and the unimplemented `"now"` ladder is disclosed once
36
+ per run (`task.injection_priority_unimplemented`); the untrusted caller steer lane gains CC's
37
+ mid-turn delivery frame.
38
+ - **A SendMessage name that changed hands refuses instead of silently redirecting** (CC's
39
+ `send_message_pin_guard` rebound arm): "Nothing was sent." plus both ways forward; a precise
40
+ task_id is never refused and re-pins; the tool description states the refusal.
41
+ - **A legacy-decided parked row converges** (#400, test P0-KPI): the live reconcile's resolved
42
+ skip now means "a claim is in flight" (read from the store row) — a checkpoint decided by the
43
+ task-level legacy lane settles the row instead of leaving a process-lifetime zombie; the
44
+ decision is re-read immediately before the verdict so a reopen CAS is never judged dead.
45
+ - **Compaction PTL retries disclose what they dropped** (#407, test P0-KPI):
46
+ `CompactionDetails.unsummarizedMessages` accumulates the true uncovered count across split
47
+ legs, with a shell-copy clause and an import-gate check. The summary prompt carries CC's
48
+ `<example>` skeleton and a compliant `<summary>` envelope is unwrapped in the persisted body
49
+ (pre-ship rescan closure: a fence-only EMPTY response is refused loudly, same class and wording
50
+ as the bare-empty arm — it could previously persist a footer-only summary).
51
+ `keepRecentToolResults` floors at 1 and refuses NaN/negative loudly.
52
+ - **#384 (keepRecentTokens default) closed as REFUTED, with evidence**: the anchor-campaign claim
53
+ that the 0 default meant "no kept tail" was disproved on a real Runner — `findCutPoint` has a
54
+ structural floor, the kept tail is never empty, and the pinned behavior matches CC's posture
55
+ via the other knob. The default is unchanged; the floor is now pinned
56
+ (test/compaction-keep-tail-floor.test.ts) and four stale anchor comments were corrected.
57
+
58
+ ### Notes
59
+ - Anchor-campaign satellite dispositions ride along (A3/A4/A8/A14 candidate tables in the
60
+ respective cars' receipts): truthfulness rewrites, registry entries, and the
61
+ PermissionDenied trigger-surface divergence registered.
62
+ - Erratum owed to sibling repos (also stated in src/core/hooks.ts): "CC fails open on hook
63
+ faults except exit 2" is true only of the command(shell) hook channel; CC's in-process
64
+ callback channel is fail-closed with dedicated copy — cited as a fail-open precedent it is
65
+ the wrong channel.
66
+ - Residuals filed, not shipped silently: #417-#423 (auth seats, Agent(<type>) governance,
67
+ lifecycle legs, postToolUse payload asymmetry, reopen fence, classic-arm Trust-but-verify).
68
+
69
+ ## 5.55.0 — 2026-08-23
70
+
71
+ ### BREAKING
72
+ - **MemoryBackend conformance suite tightens 34 → 40 clauses** (design/339 B-1): c35-c40 cover the
73
+ distilled carrier's immutability seat, the supersession read face, and plan storage. A third-party
74
+ backend green at 34 can be red at 40 — **upgrade the backend BEFORE the engine** (the suite is the
75
+ executable contract; run `memoryBackendContract` against your backend first).
76
+ - **New power face** (design/339 B-5): the engine can now restructure memory — but ONLY through
77
+ two-phase intents under a per-scope seat (all-satisfied settlement; conflict parks; delete is a
78
+ terminal-state judgment; `resolveConsolidationPlan(planId, "retry" | "discard")` is the host valve).
79
+ A deployment that assumed the engine never writes distilled products must review its mounts.
80
+ - **memory_search/get face rev 4 → 5** (design/339 §3.4): SUPERSEDED entries leave the default
81
+ result face and the MEMORY.md mechanical index (data-driven, including pre-existing stores whose
82
+ entries acquire edges); they return under `includeSuperseded` and `memory_get` renders a
83
+ supersession banner. `CleanMemorySearchHit.supersededBy` / handle-shape twin are the new seats.
84
+
85
+ ### Added
86
+ - **design/339 consolidation write protocol**: typed distilled carrier (planId/at/carrierRev
87
+ self-anchor outside the rev tuple; inputs with `superseded: true` edges), pure active-set
88
+ derivation (input-rev + carrier-rev anchors, challenge/latch suspension, chain rule, cross-scope
89
+ edges inert), four verbs (snapshot/freeze/apply/reconcile+resolve), gate-store minter accounting,
90
+ construction-time refusals, multiNode bit. New notice codes: `memory.consolidation_recommended` /
91
+ `_committed` / `_conflict` / `_refused` (+ the `memory.consolidation_*` refusal/incident family).
92
+ - **`tool_end.gatedCallId`** (top-level, optional): a park-contamination frame names the toolCallId
93
+ the committed gate holds, minted engine-side (not forgeable by tools); absent when the park holds
94
+ no call (plan review / resource slice). Interrupt/timeout aborts are byte-unchanged. **Wire
95
+ erratum**: an earlier board announcement placed this under `details` — `tool_end` frames carry no
96
+ `details`; the key is top-level.
97
+ - **Authority-envelope anti-forgery** (#391): all five harness-minted envelopes
98
+ (system-reminder / task-notification / new-diagnostics / user_memory / scope) are census-registered
99
+ (single source) and sink-neutralized by source; verbatim lanes stay byte-frozen with a positional
100
+ judgment bit. New disclosure counters `<outlet>.envelope` / `<outlet>.envelope_throttled` in
101
+ `TaskResult.stats.mechanisms.reminderDisclosures`; the trailer grows an envelope-family sentence.
102
+ - **MCP covering rules + resources-only mount** (#387/#386): `mcpRuleNameCovers` (CC's three
103
+ covering shapes) consulted in all five name-keyed lanes; resources-capable tools-less servers
104
+ mount; an out-of-charset top-level parameter name mounts WITH an operator advisory (connect leg).
105
+ - **openclaw benchmark rig** (design/184 ticket 2): HTTP-boundary comparison harness for the v3
106
+ auto-consolidation acceptance obligation (baseline bracket measured; head-to-head numbers pending
107
+ the openclaw build).
108
+
109
+ ### Fixed
110
+ - **Verification loops report the CUMULATIVE spend** (#406, test P0-KPI): runWithVerification /
111
+ resumeWithVerification returned only the last leg's stats (a $1.70 two-round run disclosed $0.70).
112
+ Every leg now folds through `buildCumulativeStats`; early-exit paths return the
113
+ accumulated-so-far account; the RB-368 unpriced axis is preserved.
114
+ - **The persisted-rule seat is time-bounded** (#415, test P0-KPI): `persistedRules.admits` was
115
+ awaited bare — a hanging provider wedged the approval gate forever. The wait now has the org
116
+ sibling's bound (15s + abort); timeout reads as could-not-answer (ask + unavailable), never a
117
+ clean no-rule.
118
+ - **MCP model-facing schemas keep validation-bearing values byte-faithful** (pre-release rescan,
119
+ fixed before publish): the intake neutralizer introduced in this window NFKC-folded enum/const/
120
+ default/examples/pattern values, so the model was shown spellings the server refuses. The schema
121
+ walk now carries a seat: prose (`description`/`title`/`$comment`) and parameter-name keys are
122
+ neutralized; validation-bearing values, unknown/vendor subtrees, and patternProperties KEYS
123
+ (regexes) transit byte-verbatim; `required`/`$ref`/`$defs`/anchor families move in lockstep with
124
+ rewritten names. **Flipped pin**: an advertised ZWSP-bearing enum member is now shown verbatim
125
+ (wire correctness over card-face literal defusing; prose/name lanes still guard spoofing).
126
+ - **Fence truncation is self-reported** (pre-release rescan): `fenceMcpServerInstructions`
127
+ pre-measured with the narrow tag set while the fence clipped the widened form — a server placing
128
+ instructions in the (cap−N, cap] window got a silent cut with no truncation note, on both the
129
+ stable and delta lanes. `delimitUntrustedWithClip` now reports its own clip; the RB-152 egress
130
+ `…[+N chars]` count stays anchored to the true source omission.
131
+ - **The consolidation scope wall holds on the read side** (pre-release rescan): a carrier in scope A
132
+ could suppress a target in scope B on every read face (edge validity never compared scopes; a
133
+ supported cross-scope MOVE keeps the rev byte-identical). Cross-scope edges are now inert —
134
+ existing stores: any cross-scope-suppressed entry RETURNS to the default face (revival direction).
135
+ - **The rewrite terminal carries provenance/trust**: dropping them minted the whitewash patch every
136
+ conforming backend must refuse (the plan parked forever holding the scope seat); a trust-only
137
+ entry was instead silently whitewashed on stores keying the guard on provenance.kind. Both fields
138
+ now carry forward beside origin/distilled (this also corrects plannedPostRev).
139
+ - **The CONFLICT notice stops overstating** (server/cli pins on the old string will red): it names
140
+ all three park causes (concurrent write / store-declined patch / re-judgment) and scopes the
141
+ no-overwrite claim to REFUSED targets (applied siblings stand; discard rolls nothing back);
142
+ retry-synthesized parks now write their own per-target audit rows.
143
+ - **memory_get pages the displayed string**: budgets/cursors previously addressed the raw body while
144
+ the fence displayed a longer neutralized one; the body is now neutralized once into a canonical
145
+ line array every page and cursor reads. Envelope-tag-bearing bodies page differently (fewer lines
146
+ per page, never over budget); marker-free bodies are byte-identical.
147
+ - **The third memory composer joins the widened tag family** (#391 class sweep):
148
+ `composeSelectiveBody` hard-coded `["user_memory"]` in its three sanitize seats while its two
149
+ siblings were widened to `MEMORY_INJECTION_TAGS`.
150
+ - **Contract texts catch up with minted disclosure keys**: `envelope`/`envelope_throttled`/
151
+ `mark_echo` join the `reminderDisclosures` JSDoc and docs/REMINDER-PROVENANCE.md (ratchet-pinned:
152
+ minted set ⊆ both texts; DISCLOSED tag set must be named in both).
153
+
154
+ ### Notes
155
+ - Conformance fixture rename (`id-legacy-0001` → `id-plain-0001`); 14 `memory.consolidation_*`
156
+ codes registered non-governance (mechanism receipts, not per-principal verdicts); type-hygiene
157
+ memory-engine checked-cast baseline 14 → 23 (each behind a runtime validator added in the same
158
+ commits).
159
+ - Residuals filed, not shipped silently: #414① (refresh-leg advisory drop), #416 (loosenReasons
160
+ covering-awareness — a published-corpus change, scheduled with the names window), patternProperties
161
+ `$ref` pointer form (disclosed at the seat table).
162
+
3
163
  ## 5.54.0 — 2026-08-22
4
164
 
5
165
  ### Fixed
@@ -83,3 +83,29 @@ export interface CumulativeStatsAccumulator {
83
83
  * base: a ratio is not additive, so the last leg's rate is never the operation's rate.
84
84
  */
85
85
  export declare function buildCumulativeStats(base: TaskResult["stats"], acc: CumulativeStatsAccumulator): TaskResult["stats"];
86
+ /** A running {@link CumulativeStatsAccumulator} with the per-leg fold built in. */
87
+ export interface CumulativeStatsTracker {
88
+ /** Fold ONE leg's stats into the running totals. Call once per leg, in dispatch order. */
89
+ add(s: TaskResult["stats"]): void;
90
+ /** Build the cumulative stats over `base` — conventionally the FINAL leg's stats (it carries the
91
+ * non-accumulated fields such as `model`). */
92
+ build(base: TaskResult["stats"]): TaskResult["stats"];
93
+ /** How many legs have been folded in. A composition that ran exactly ONE leg can use this to hand that
94
+ * leg's own stats back untouched: the cumulative account over one leg IS that leg, and re-deriving it
95
+ * would rewrite a caller's object (0 → `undefined` normalization, a re-derived `cacheHitRate`) for no
96
+ * gain. */
97
+ readonly legs: number;
98
+ }
99
+ /**
100
+ * Create a {@link CumulativeStatsTracker}.
101
+ *
102
+ * {@link buildCumulativeStats} made the OUTPUT literal single-source, but the INPUT fold stayed copied:
103
+ * cascade / repair-loop / teacher each hand-maintain the same dozen `+=` lines, so a new stats field still
104
+ * has to be remembered in four places — the same duplication one level down from the hole this module
105
+ * exists to close. New call sites take this factory instead. Migrating the three originals onto it is a
106
+ * separate change (their folds are equivalent to this one today, but rewriting three shipped compositions
107
+ * is not part of a cost-accounting bug fix).
108
+ *
109
+ * OWN and NESTED stay DISJOINT here too: `add` never folds a leg's `nested.*` into the own totals.
110
+ */
111
+ export declare function createCumulativeStatsTracker(): CumulativeStatsTracker;
@@ -27,3 +27,59 @@ export function buildCumulativeStats(base, acc) {
27
27
  : undefined,
28
28
  };
29
29
  }
30
+ export function createCumulativeStatsTracker() {
31
+ let legs = 0;
32
+ const acc = {
33
+ tokens: 0,
34
+ turns: 0,
35
+ promptTokens: 0,
36
+ totalInputTokens: 0,
37
+ cachedTokens: 0,
38
+ outputTokens: 0,
39
+ cacheWriteTokens: 0,
40
+ cacheWriteTokensLong: 0,
41
+ costMicroUsd: 0,
42
+ anyLegUnpriced: false,
43
+ llmRootMicroUsd: 0,
44
+ compactionMicroUsd: 0,
45
+ nested: { tokens: 0, turns: 0, tasks: 0, costMicroUsd: 0 },
46
+ humanReview: { count: 0, totalWaitMs: 0, gates: [] },
47
+ };
48
+ return {
49
+ get legs() {
50
+ return legs;
51
+ },
52
+ add(s) {
53
+ legs += 1;
54
+ acc.tokens += s.tokens;
55
+ acc.turns += s.turns;
56
+ acc.promptTokens += s.promptTokens ?? 0;
57
+ acc.totalInputTokens += s.totalInputTokens ?? 0;
58
+ acc.cachedTokens += s.cachedTokens ?? 0;
59
+ acc.outputTokens += s.outputTokens ?? 0;
60
+ acc.cacheWriteTokens += s.cacheWriteTokens ?? 0;
61
+ acc.cacheWriteTokensLong += s.cacheWriteTokensLong ?? 0;
62
+ acc.costMicroUsd = (acc.costMicroUsd ?? 0) + (s.costMicroUsd ?? 0);
63
+ if (s.costMicroUsd === undefined)
64
+ acc.anyLegUnpriced = true;
65
+ if (s.costBreakdown) {
66
+ acc.llmRootMicroUsd += s.costBreakdown.llmRootMicroUsd;
67
+ acc.compactionMicroUsd += s.costBreakdown.compactionMicroUsd;
68
+ }
69
+ if (s.humanReview) {
70
+ acc.humanReview.count += s.humanReview.count;
71
+ acc.humanReview.totalWaitMs += s.humanReview.totalWaitMs;
72
+ acc.humanReview.gates.push(...s.humanReview.gates);
73
+ }
74
+ if (s.nested) {
75
+ acc.nested.tokens += s.nested.tokens;
76
+ acc.nested.turns += s.nested.turns;
77
+ acc.nested.tasks += s.nested.tasks;
78
+ acc.nested.costMicroUsd += s.nested.costMicroUsd ?? 0;
79
+ }
80
+ },
81
+ build(base) {
82
+ return buildCumulativeStats(base, acc);
83
+ },
84
+ };
85
+ }
@@ -17,6 +17,7 @@
17
17
  import { Type } from "typebox";
18
18
  import type { AgentDefinition, TaskEvent } from "../core/types.js";
19
19
  import { defineTool } from "../core/tools.js";
20
+ import { escapeEnvelopeTag } from "../core/untrusted-text.js";
20
21
  /** The four tagged digest event kinds (CC `Tcg`) — `assistant_text` renders untagged. */
21
22
  export declare const OBSERVER_EVENT_TAGS: readonly ["tool-call", "user-message", "tool-result", "turn-ended"];
22
23
  export type ObserverDigestEvent = {
@@ -39,14 +40,17 @@ export type ObserverDigestEvent = {
39
40
  /** CC `S$r` — anti-injection: a caller-declared envelope tag inside observed content is defused in place. */
40
41
  export declare function escapeObserverTags(text: string): string;
41
42
  /**
42
- * CC `zZe` @8339092 — verbatim: `t.replace(new RegExp(`<(?=/?${e}(?:[>\\s/]|$))`, "gi"), "<\\")`.
43
- * Defuses a caller-declared occurrence of ONE specific tag (opening AND closing) inside content the second
44
- * escape layer CC applies to the DYNAMIC envelope tag (`<{slug}-activity>`) after joining, which the
45
- * static four-tag `escapeObserverTags` (Scg layer) cannot cover. Deviation from CC (hardening): the
46
- * tag is regex-escaped before entering the RegExp CC interpolates raw, safe only because its slugs
47
- * are `[a-zA-Z0-9_-]`; ours are too, but we don't rely on the caller for that invariant.
43
+ * CC `zZe` @8339092 — the named-envelope tag escape. It is the second escape layer CC applies to the
44
+ * DYNAMIC envelope tag (`<{slug}-activity>`) after joining, which the static four-tag
45
+ * {@link escapeObserverTags} (Scg layer) cannot cover.
46
+ *
47
+ * RE-HOMED to `core/untrusted-text.ts` beside the ENGINE_ENVELOPES registry (whose guard column
48
+ * points at it): "escape one named envelope tag" is a containment primitive, not an observer
49
+ * concern, and having it live under `agents/` is what let two later envelopes (`skills`, `scope`)
50
+ * ship with no guard — nothing in the containment module named it. Re-exported here so this
51
+ * module's own call sites and `send-message-tool.ts` are unchanged.
48
52
  */
49
- export declare function escapeEnvelopeTag(tag: string, text: string): string;
53
+ export { escapeEnvelopeTag };
50
54
  /** CC `Yp` @8338930 — attribute-value escape: `oc` entities (& < >) plus quote/apostrophe. */
51
55
  export declare function escapeAttributeValue(value: string): string;
52
56
  /** CC `Eqi` = 2000 — the per-payload digest truncation bound (tool inputs/results, user messages). */
@@ -1,15 +1,13 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool } from "../core/tools.js";
3
3
  import { createSafeNotifier } from "../core/safe-notify.js";
4
+ import { escapeEnvelopeTag } from "../core/untrusted-text.js";
4
5
  export const OBSERVER_EVENT_TAGS = ["tool-call", "user-message", "tool-result", "turn-ended"];
5
6
  const TAG_ESCAPE_RE = new RegExp(`<(?=/?(?:${OBSERVER_EVENT_TAGS.join("|")})(?:[>\\s/]|$))`, "gi");
6
7
  export function escapeObserverTags(text) {
7
8
  return text.replace(TAG_ESCAPE_RE, "<\\");
8
9
  }
9
- export function escapeEnvelopeTag(tag, text) {
10
- const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11
- return text.replace(new RegExp(`<(?=/?${escapedTag}(?:[>\\s/]|$))`, "gi"), "<\\");
12
- }
10
+ export { escapeEnvelopeTag };
13
11
  export function escapeAttributeValue(value) {
14
12
  return value
15
13
  .replaceAll("&", "&amp;")
@@ -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,35 @@ 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 sendMessagePins = new Map();
50
+ const pinGuard = (targetId, targetName, rung, to) => {
51
+ if (rung === "other")
52
+ return undefined;
53
+ const pinKey = normalizeAgentName(targetName ?? "");
54
+ if (pinKey === "")
55
+ return undefined;
56
+ const pinned = sendMessagePins.get(pinKey);
57
+ if (pinned === undefined || pinned.id === targetId) {
58
+ sendMessagePins.set(pinKey, { id: targetId, name: targetName ?? to });
59
+ return undefined;
60
+ }
61
+ if (rung === "ref") {
62
+ sendMessagePins.set(pinKey, { id: targetId, name: targetName ?? to });
63
+ return undefined;
64
+ }
65
+ if (targetName !== undefined && normalizeAgentName(to) === pinKey && normalizeAgentName(pinned.name) !== pinKey) {
66
+ return undefined;
67
+ }
68
+ return {
69
+ content: `Message not sent: "${inlineUntrusted(to, 120)}" now resolves to a different agent than it did earlier in ` +
70
+ `this run — this name previously resolved to ${pinned.id.slice(0, 64)}, which it no longer reaches. Nothing was sent.\n` +
71
+ `It now resolves to ${targetId.slice(0, 64)}. To message that agent, re-send with its task_id: ` +
72
+ `{"to": "${targetId.slice(0, 64)}", …}. To reach the earlier agent, use its task_id ` +
73
+ `${pinned.id.slice(0, 64)} from its spawn result.`,
74
+ details: { error: "rebound", to, taskId: targetId, previousTaskId: pinned.id },
75
+ isError: true,
76
+ };
77
+ };
48
78
  return defineTool({
49
79
  name: SEND_MESSAGE_TOOL_NAME,
50
80
  contract: { contractId: "core.send_message@1", implementationRevision: "1" },
@@ -54,8 +84,9 @@ export function createSendMessageTool(opts) {
54
84
  `'to' is the agent's name (set at spawn via Agent({name})) or its task_id (a…). A RUNNING agent receives the ` +
55
85
  `message at its next turn (queued — never interrupts its current work); a FINISHED agent resumes as a new ` +
56
86
  `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 ` +
87
+ `keep working after an agent completes; but if a name you already messaged is later taken by a NEWER agent, the ` +
88
+ `send is refused rather than silently redirected re-send with the task_id of whichever agent you meant. ` +
89
+ `You will be notified automatically when it completes — prefer ending your turn; do not ` +
59
90
  `poll. ` +
60
91
  (tier3Capable
61
92
  ? `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.`
@@ -523,6 +554,11 @@ export function createSendMessageTool(opts) {
523
554
  }
524
555
  }
525
556
  if (rosterHit !== undefined) {
557
+ {
558
+ const guard = pinGuard(rosterHit.agentId, to, "name", to);
559
+ if (guard !== undefined)
560
+ return guard;
561
+ }
526
562
  const revived = await tier3Revive(rosterHit.agentId);
527
563
  if (revived !== undefined)
528
564
  return revived;
@@ -563,6 +599,16 @@ export function createSendMessageTool(opts) {
563
599
  if (row.type !== "background_agent") {
564
600
  return { content: `Message not sent: ${targetId} is a ${row.type} task, not a background agent.`, details: { error: "wrong_type", to }, isError: true };
565
601
  }
602
+ {
603
+ const rung = idRow !== undefined
604
+ ? "ref"
605
+ : row.name !== undefined && (row.name === to || normalizeAgentName(row.name) === normalizeAgentName(to))
606
+ ? "name"
607
+ : "other";
608
+ const guard = pinGuard(targetId, row.name, rung, to);
609
+ if (guard !== undefined)
610
+ return guard;
611
+ }
566
612
  if (row.status === "running" || row.status === "pending") {
567
613
  return await withTargetLane(targetLaneKey(access.scope, targetId), async () => {
568
614
  const liveRecipientKey = peerAxisToken(resolvedAccess.scope, "h", targetId);