@sema-agent/core 7.1.0 → 7.3.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 (64) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/agents/cross-session-envelope.d.ts +145 -0
  3. package/dist/agents/cross-session-envelope.js +195 -0
  4. package/dist/agents/cross-session-judge.d.ts +119 -0
  5. package/dist/agents/cross-session-judge.js +184 -0
  6. package/dist/agents/cross-session-ref.d.ts +52 -0
  7. package/dist/agents/cross-session-ref.js +64 -0
  8. package/dist/agents/list-agents-tool.d.ts +55 -0
  9. package/dist/agents/list-agents-tool.js +94 -0
  10. package/dist/agents/peer-admission.d.ts +17 -1
  11. package/dist/agents/peer-admission.js +19 -2
  12. package/dist/agents/peer-directory.d.ts +208 -0
  13. package/dist/agents/peer-directory.js +272 -0
  14. package/dist/agents/peer-session-drain.d.ts +159 -0
  15. package/dist/agents/peer-session-drain.js +245 -0
  16. package/dist/agents/send-message-tool.d.ts +44 -0
  17. package/dist/agents/send-message-tool.js +181 -16
  18. package/dist/agents/subagent-steps.d.ts +11 -0
  19. package/dist/agents/subagent-steps.js +27 -4
  20. package/dist/core/auto-mode-arming.d.ts +11 -0
  21. package/dist/core/auto-mode-arming.js +7 -1
  22. package/dist/core/auto-mode-prompt.d.ts +5 -0
  23. package/dist/core/auto-mode-prompt.js +2 -1
  24. package/dist/core/auto-mode-rebuild.d.ts +2 -1
  25. package/dist/core/auto-mode-rebuild.js +2 -0
  26. package/dist/core/checkpoint-store.d.ts +203 -3
  27. package/dist/core/checkpoint-store.js +60 -19
  28. package/dist/core/governance-codes.d.ts +1 -1
  29. package/dist/core/governance-codes.js +6 -0
  30. package/dist/core/hooks.d.ts +15 -8
  31. package/dist/core/hooks.js +6 -3
  32. package/dist/core/mailbox-store.d.ts +89 -2
  33. package/dist/core/mailbox-store.js +77 -2
  34. package/dist/core/permission-rule-consent.d.ts +72 -23
  35. package/dist/core/permission-rule-consent.js +115 -26
  36. package/dist/core/permission-rule-model.d.ts +254 -51
  37. package/dist/core/permission-rule-model.js +316 -55
  38. package/dist/core/permission-rule-org.js +13 -6
  39. package/dist/core/remote-env.d.ts +8 -1
  40. package/dist/core/runner/assemble-result.js +2 -1
  41. package/dist/core/runner/prepare-task.d.ts +59 -1
  42. package/dist/core/runner/prepare-task.js +414 -149
  43. package/dist/core/runner/prepare-workspace-restore.d.ts +6 -1
  44. package/dist/core/runner/prepare-workspace-restore.js +2 -1
  45. package/dist/core/runner/runtask.js +16 -5
  46. package/dist/core/runner/tool-output-projection.js +1 -0
  47. package/dist/core/store-contracts/mailbox-store-contract.d.ts +23 -0
  48. package/dist/core/store-contracts/mailbox-store-contract.js +157 -1
  49. package/dist/core/task-notification.d.ts +93 -5
  50. package/dist/core/task-notification.js +31 -4
  51. package/dist/core/tool-policy.d.ts +11 -0
  52. package/dist/core/types.d.ts +155 -21
  53. package/dist/core/untrusted-text.js +17 -1
  54. package/dist/core/wiring-manifest.d.ts +21 -0
  55. package/dist/core/wiring-manifest.js +1 -0
  56. package/dist/index.d.ts +14 -5
  57. package/dist/index.js +13 -4
  58. package/dist/stores/cc/mailbox-store.d.ts +1 -1
  59. package/dist/stores/cc/mailbox-store.js +13 -0
  60. package/dist/stores/file/adoption/marker.d.ts +1 -1
  61. package/dist/stores/file/mailbox-store.d.ts +57 -0
  62. package/dist/stores/file/mailbox-store.js +369 -18
  63. package/package.json +1 -1
  64. package/test/export-surface.snapshot.json +233 -1
@@ -0,0 +1,245 @@
1
+ import { mailboxCrossProcessMountVerdict } from "../core/mailbox-store.js";
2
+ import { deliverEngineNotice } from "../core/types.js";
3
+ import { buildCrossSessionEnvelope, CrossSessionCodecError, encodePeerAddress } from "./cross-session-envelope.js";
4
+ import { judgeCrossSessionInbound, resolveCrossSessionInboundSetting } from "./cross-session-judge.js";
5
+ import { judgePeerAdmission, peerAxisToken, resolvePeerAdmissionConfig } from "./peer-admission.js";
6
+ import { isPeerSessionId, PEER_SESSION_ID_GRAMMAR, peerSessionBoxHandle } from "./peer-directory.js";
7
+ import { inlineUntrusted } from "../core/untrusted-text.js";
8
+ import { createListAgentsTool, LIST_AGENTS_TOOL_ALIAS, LIST_AGENTS_TOOL_NAME } from "./list-agents-tool.js";
9
+ import { foldPermissionModeClass } from "./cross-session-judge.js";
10
+ export const PEER_DRAIN_LEASE_TTL_MS = 5 * 60_000;
11
+ export const PEER_SESSION_ID_UNGRAMMATICAL_CODE = "peer.session_id_ungrammatical";
12
+ export function judgePeerLaneMount(deps, sessionId) {
13
+ if (deps.peerDirectory === undefined)
14
+ return undefined;
15
+ if (deps.mailboxStore === undefined) {
16
+ return { ok: false, code: "mailbox.cross_process_unsafe", reason: "RunnerDeps.peerDirectory is wired but RunnerDeps.mailboxStore is not — the cross-session lane parks messages in the recipient's durable session box and has no store to park them in" };
17
+ }
18
+ const store = mailboxCrossProcessMountVerdict(deps.mailboxStore);
19
+ if (!store.ok)
20
+ return store;
21
+ if (sessionId !== undefined && !isPeerSessionId(sessionId)) {
22
+ return {
23
+ ok: false,
24
+ code: PEER_SESSION_ID_UNGRAMMATICAL_CODE,
25
+ reason: `this run's session id ${JSON.stringify(inlineUntrusted(sessionId, 96))} is outside the peer address grammar ${PEER_SESSION_ID_GRAMMAR} — it has no session box to drain and no address a peer could send to (mint session ids the grammar accepts to use the lane)`,
26
+ };
27
+ }
28
+ return { ok: true };
29
+ }
30
+ export function createPeerSessionDrain(opts) {
31
+ const { mailbox, sessionId, runId, scope } = opts;
32
+ const boxHandle = peerSessionBoxHandle(sessionId);
33
+ const drainOwner = `drain:${runId}`;
34
+ const recipientKey = peerAxisToken(scope, "s", sessionId);
35
+ const ttl = opts.leaseTtlMs ?? PEER_DRAIN_LEASE_TTL_MS;
36
+ let settledUpTo;
37
+ const disclosedHeld = new Set();
38
+ let inFlight = Promise.resolve();
39
+ const disclose = (disposition, cause, m, heldNote = "the held-message review face is not mounted in this build") => deliverEngineNotice(opts.onNotice, {
40
+ code: "peer.inbound_disposition",
41
+ message: `cross-session message seq ${m.seq} in this session's box was ${disposition === "held" ? `HELD (it stays parked; ${heldNote})` : disposition === "refused" ? "refused by this session's crossSessionInbound setting" : disposition === "notice_unrouted" ? "settled WITHOUT delivery (a notice-kind record; the notice face is not mounted in this build)" : "refused by the inbound admission gate"}: ${cause}`,
42
+ detail: { disposition, cause, seq: m.seq, box: boxHandle, ...(m.peerMeta?.fromSession !== undefined ? { fromSession: m.peerMeta.fromSession } : {}), sessionId, runId },
43
+ });
44
+ const ackSettled = async () => {
45
+ if (settledUpTo === undefined)
46
+ return;
47
+ const upTo = settledUpTo;
48
+ settledUpTo = undefined;
49
+ try {
50
+ await mailbox.ack(scope, boxHandle, drainOwner, upTo);
51
+ }
52
+ catch (e) {
53
+ opts.onError?.(e, { phase: "degraded", sessionId, classification: "peer-box-ack" });
54
+ }
55
+ };
56
+ const drainOnce = async () => {
57
+ let lease;
58
+ try {
59
+ lease = await mailbox.claimLease(scope, boxHandle, drainOwner, ttl);
60
+ }
61
+ catch (e) {
62
+ opts.onError?.(e, { phase: "degraded", sessionId, classification: "peer-box-claim" });
63
+ return;
64
+ }
65
+ const previouslySettled = settledUpTo;
66
+ await ackSettled();
67
+ if (lease === null)
68
+ return;
69
+ let setting;
70
+ let admissionConfig;
71
+ let selfModeClass;
72
+ try {
73
+ setting = resolveCrossSessionInboundSetting(opts.settingLayers() ?? {});
74
+ admissionConfig = resolvePeerAdmissionConfig(opts.admission);
75
+ selfModeClass = opts.selfModeClass();
76
+ }
77
+ catch (e) {
78
+ opts.onError?.(e, { phase: "degraded", sessionId, classification: "peer-box-settings" });
79
+ return;
80
+ }
81
+ for (const m of lease.messages) {
82
+ if (previouslySettled !== undefined && m.seq <= previouslySettled)
83
+ continue;
84
+ const meta = m.peerMeta;
85
+ if (m.hopChain !== undefined) {
86
+ const verdict = judgePeerAdmission(scope, recipientKey, { senderKey: meta?.senderKey, body: m.content, prospectiveChain: m.hopChain, ownTokens: opts.ownTokens(), seq: m.seq }, admissionConfig, undefined, "drain");
87
+ if (!verdict.ok) {
88
+ if (verdict.reason === "rate_limited") {
89
+ if (!disclosedHeld.has(m.seq)) {
90
+ disclosedHeld.add(m.seq);
91
+ disclose("held", verdict.reason, m, "the inbound admission bucket is empty; it is re-judged at a later boundary once the bucket refills");
92
+ }
93
+ break;
94
+ }
95
+ disclose("admission_refused", verdict.reason, m);
96
+ settledUpTo = m.seq;
97
+ continue;
98
+ }
99
+ }
100
+ if (meta?.kind !== undefined && meta.kind !== "peer_message") {
101
+ disclose("notice_unrouted", `kind=${meta.kind}`, m);
102
+ settledUpTo = m.seq;
103
+ continue;
104
+ }
105
+ {
106
+ const judged = judgeCrossSessionInbound({ setting, selfModeClass, sender: { ...(meta?.fromMode !== undefined ? { fromMode: meta.fromMode } : {}), selfSent: meta?.fromSession !== undefined && meta.fromSession.toLowerCase() === sessionId.toLowerCase() } });
107
+ if (judged.verdict === "refuse") {
108
+ disclose("refused", judged.cause, m);
109
+ settledUpTo = m.seq;
110
+ continue;
111
+ }
112
+ if (judged.verdict === "hold") {
113
+ if (!disclosedHeld.has(m.seq)) {
114
+ disclosedHeld.add(m.seq);
115
+ disclose("held", `${judged.cause}: ${judged.message}${judged.warning !== undefined ? ` (${judged.warning})` : ""}`, m);
116
+ }
117
+ break;
118
+ }
119
+ }
120
+ let fields;
121
+ try {
122
+ fields = {
123
+ from: encodePeerAddress(meta?.fromSession !== undefined ? peerSessionBoxHandle(meta.fromSession) : (m.from ?? "unknown")),
124
+ ...(meta?.fromSession !== undefined ? { fromSession: meta.fromSession } : {}),
125
+ ...(m.from !== undefined ? { fromName: m.from } : {}),
126
+ ...(meta?.fromMode !== undefined ? { fromMode: meta.fromMode } : {}),
127
+ ...(meta?.fromScope !== undefined ? { fromScope: meta.fromScope } : {}),
128
+ };
129
+ buildCrossSessionEnvelope(fields, m.content);
130
+ }
131
+ catch (e) {
132
+ disclose("refused", `record fields cannot be rendered canonically (${e instanceof CrossSessionCodecError ? e.code : e instanceof Error ? e.message : String(e)})`, m);
133
+ settledUpTo = m.seq;
134
+ continue;
135
+ }
136
+ const payload = {
137
+ task_id: boxHandle,
138
+ task_type: "background_agent",
139
+ status: "event",
140
+ summary: `cross-session message from ${m.from ?? fields.from}`,
141
+ result: m.content,
142
+ seq: m.seq,
143
+ peer: { hopChain: m.hopChain !== undefined ? [...m.hopChain] : [] },
144
+ crossSessionMessage: { ...fields, body: m.content },
145
+ _sema_provenance: { kind: "cross_session_message", from: fields.from, taskId: boxHandle, seq: m.seq, ...(meta !== undefined ? { peerMeta: { ...meta } } : {}) },
146
+ };
147
+ try {
148
+ opts.inject(payload, { priority: "next" });
149
+ }
150
+ catch (e) {
151
+ opts.onError?.(e, { phase: "degraded", sessionId, classification: "peer-box-inject" });
152
+ break;
153
+ }
154
+ settledUpTo = m.seq;
155
+ }
156
+ };
157
+ const drain = () => {
158
+ inFlight = inFlight.then(drainOnce, drainOnce);
159
+ return inFlight;
160
+ };
161
+ const finishOnce = async () => {
162
+ await ackSettled();
163
+ try {
164
+ await mailbox.releaseLease(scope, boxHandle, drainOwner);
165
+ }
166
+ catch {
167
+ }
168
+ };
169
+ const finish = () => {
170
+ inFlight = inFlight.then(finishOnce, finishOnce);
171
+ return inFlight;
172
+ };
173
+ return { drain, finish };
174
+ }
175
+ export function announcePeerLaneMount(deps, leg) {
176
+ const verdict = judgePeerLaneMount(deps, leg.sessionId);
177
+ if (verdict === undefined)
178
+ return false;
179
+ if (verdict.ok)
180
+ return true;
181
+ deliverEngineNotice(deps.onNotice, {
182
+ code: "config.peer_lane_unmounted",
183
+ message: `Cross-session lane NOT mounted for this run: ${verdict.reason}. ListAgents is not mounted and peer-session addresses refuse with that reason${verdict.code === PEER_SESSION_ID_UNGRAMMATICAL_CODE ? "." : "; wire a mailbox store that declares crossProcessSafe: true (the bundled FileMailboxStore does)."}`,
184
+ detail: { reason: verdict.reason, code: verdict.code, mailboxWired: deps.mailboxStore !== undefined, sessionId: leg.sessionId, runId: leg.runId },
185
+ });
186
+ return false;
187
+ }
188
+ export function peerLaneSendMessageSeats(args) {
189
+ return {
190
+ peerDirectory: args.peerDirectory,
191
+ ...(args.listingMounted !== undefined ? { peerListingMounted: args.listingMounted } : {}),
192
+ peerSelfSession: {
193
+ sessionId: args.sessionId,
194
+ scope: args.scope,
195
+ ...(args.name !== undefined ? { name: args.name } : {}),
196
+ modeClass: () => (args.refs.askEffective !== undefined ? foldPermissionModeClass(args.refs.askEffective) : "unknown"),
197
+ },
198
+ };
199
+ }
200
+ export function listAgentsMountable(face) {
201
+ if ((face.exclude ?? []).includes(LIST_AGENTS_TOOL_NAME))
202
+ return false;
203
+ return !face.specTools.some((t) => t.name === LIST_AGENTS_TOOL_NAME || t.name === LIST_AGENTS_TOOL_ALIAS || t.aliases?.includes(LIST_AGENTS_TOOL_NAME) || t.aliases?.includes(LIST_AGENTS_TOOL_ALIAS));
204
+ }
205
+ export function mountListAgents(args) {
206
+ if (!listAgentsMountable({ exclude: undefined, specTools: args.specTools }))
207
+ return undefined;
208
+ args.toolEffects.set(LIST_AGENTS_TOOL_NAME, "read");
209
+ args.toolEffects.set(LIST_AGENTS_TOOL_ALIAS, "read");
210
+ return createListAgentsTool({
211
+ peerDirectory: args.peerDirectory,
212
+ self: { sessionId: args.sessionId, scope: args.scope },
213
+ registry: args.registry,
214
+ access: { owner: args.hostTaskId, scope: args.scope, sessionId: args.sessionId },
215
+ ...(args.parentTaskId !== undefined ? { parentAccess: { owner: args.parentTaskId, scope: args.scope, ...(args.parentSessionId !== undefined ? { sessionId: args.parentSessionId } : {}) } } : {}),
216
+ ...(args.roster !== undefined ? { roster: args.roster } : {}),
217
+ });
218
+ }
219
+ export function bindPeerSessionDrain(harness, opts) {
220
+ const drain = createPeerSessionDrain(opts);
221
+ const round = async () => {
222
+ await drain.drain();
223
+ return undefined;
224
+ };
225
+ harness.on("before_agent_start", round);
226
+ harness.on("turn_boundary", round);
227
+ harness.subscribe((event) => (event.type === "agent_end" ? drain.finish().catch(() => undefined) : undefined));
228
+ return drain;
229
+ }
230
+ export function bindPeerLaneDrain(harness, args) {
231
+ const { deps } = args;
232
+ return bindPeerSessionDrain(harness, {
233
+ mailbox: deps.mailboxStore,
234
+ sessionId: args.sessionId,
235
+ runId: args.runId,
236
+ scope: args.scope,
237
+ inject: args.inject,
238
+ ...(deps.peerAdmission !== undefined ? { admission: deps.peerAdmission } : {}),
239
+ ownTokens: args.ownTokens,
240
+ settingLayers: () => (typeof deps.crossSessionInbound === "function" ? deps.crossSessionInbound() : deps.crossSessionInbound),
241
+ selfModeClass: () => (args.refs.askEffective !== undefined ? foldPermissionModeClass(args.refs.askEffective) : "unknown"),
242
+ ...(deps.onNotice !== undefined ? { onNotice: deps.onNotice } : {}),
243
+ ...(deps.onError !== undefined ? { onError: deps.onError } : {}),
244
+ });
245
+ }
@@ -3,6 +3,8 @@ import type { Runner } from "../core/runner/runtask.js";
3
3
  import type { TaskNotificationPayload } from "../core/task-notification.js";
4
4
  import { type ToolCtxEnricher } from "../core/tools.js";
5
5
  import { type TaskAccess } from "../core/task-registry.js";
6
+ import type { PermissionModeClass } from "./cross-session-envelope.js";
7
+ import { type PeerDirectory } from "./peer-directory.js";
6
8
  import { SubagentRetainLedger } from "./retain-ledger.js";
7
9
  import { type SubagentSteerHandle } from "./subagent.js";
8
10
  import { type PeerAdmissionConfig, type PeerInboundChainRef, type PeerSelfRef } from "./peer-admission.js";
@@ -44,6 +46,19 @@ export interface SendMessageToolOptions {
44
46
  * this run's process exits when the turn ends, so no completion notification can ever land — checked
45
47
  * AHEAD of {@link notificationWired}, since no amount of wiring makes a later turn exist. */
46
48
  oneShot?: boolean;
49
+ /**
50
+ * Whether a RETRIEVAL tool (`TaskOutput`) is mounted beside this tool on the same face. Every
51
+ * degraded completion promise on this face (the one-shot and silent arms of the description and
52
+ * the receipt/refusal texts) tells the model how to collect a continued agent's result; naming
53
+ * `TaskOutput(task_id, block: true)` as an imperative is only true where that tool exists. Since
54
+ * design/385 §1.5 this tool also mounts on the delegated-child arm WITHOUT the background door
55
+ * TaskOutput/TaskStop keep, so the two can no longer be assumed to travel together. Three states:
56
+ * `true` — the advice names TaskOutput plainly; `false` — this face has no retrieval tool, and the
57
+ * advice says the result cannot be collected by this run instead of naming a tool the model cannot
58
+ * call (an unknown-tool error on a one-shot run is the turn where the result is lost); absent — the
59
+ * mount did not say, and the advice is hedged ("where that tool is mounted").
60
+ */
61
+ retrievalToolMounted?: boolean;
47
62
  /** Steer-handle sink: the revived run re-emits a FRESH handle (design/122 risk-table contract). */
48
63
  sink?: (handle: SubagentSteerHandle) => void;
49
64
  /**
@@ -151,6 +166,35 @@ export interface SendMessageToolOptions {
151
166
  * per-call honest refusal is the only loudness (unchanged behavior).
152
167
  */
153
168
  onTranscriptIntegrityGap?: (handle: string, scope: string | undefined) => void;
169
+ /**
170
+ * design/385 §2 — the peer-session DIRECTORY ({@link import("../core/types.js").RunnerDeps.peerDirectory}):
171
+ * opens the cross-session lane's two rungs on this face — the explicit `session.<id>` address arm
172
+ * (ranked beside the precise a* id) and the LAST name rung (`name [ref]` over the directory's full
173
+ * set, after every agent rung missed). Needs {@link mailbox} beside it AND that store's
174
+ * `crossProcessSafe: true` declaration: with the directory wired but either missing, the lane stays
175
+ * closed and every peer-addressed send refuses with the named reason (never a silent fall-through to
176
+ * "no agent matches"). Absent ⇒ byte-identical pre-385 behavior.
177
+ */
178
+ peerDirectory?: PeerDirectory;
179
+ /**
180
+ * design/385 — THIS session's own identity on the cross-session lane: its session id (the sender's
181
+ * `from-session` / reply address, and the self-target guard), the scope its directory rows live in,
182
+ * its display name (the `from-name` attestation), and the live fold of its own permission-mode class
183
+ * ({@link import("./cross-session-judge.js").foldPermissionModeClass} over the wiring manifest's
184
+ * `ask.effective`) — read at SEND time, attested on the parked record's typed `peerMeta.fromMode`.
185
+ * The Runner mount fills it; a direct mount without it sends with no session attestation (the
186
+ * recipient then judges it as "no class asserted").
187
+ */
188
+ peerSelfSession?: {
189
+ sessionId: string;
190
+ scope?: string;
191
+ name?: string;
192
+ modeClass?: () => PermissionModeClass | "unknown";
193
+ };
194
+ /** design/385 §5.1 — is `ListAgents` mounted beside this tool? Drives the "list them with ListAgents"
195
+ * phrase (a face never names a tool the roster does not carry — an `excludeTools` may drop one of the
196
+ * pair). Absent = unknown ⇒ the phrase is hedged. */
197
+ peerListingMounted?: boolean;
154
198
  }
155
199
  /** RB-382 — max chars of the `summary` arg (the short recap label, distinct from `message`'s much
156
200
  * larger {@link UPLINK_RESULT_MAX}) before ITS OWN truncation. Exported for direct unit-testing only
@@ -3,9 +3,12 @@ import { defineTool } from "../core/tools.js";
3
3
  import { normalizeAgentName, DURABLE_AGENT_HANDLE_RE, DURABLE_AGENT_HEARTBEAT_MS } from "../core/task-registry.js";
4
4
  import { announceTranscriptIntegrityGapOnce, canAccessAgentRecord, clearRevivedRowTerminalPayload } from "../core/background-agent-store.js";
5
5
  import { uuidv7 } from "../internal/harness.js";
6
- import { MAILBOX_TOMBSTONED_RECIPIENT_CODE } from "../core/mailbox-store.js";
6
+ import { MAILBOX_TOMBSTONED_RECIPIENT_CODE, mailboxCrossProcessMountVerdict } from "../core/mailbox-store.js";
7
+ import { peerSendVerdictSeverity } from "./cross-session-judge.js";
8
+ import { parsePeerSessionAddress, peerSessionBoxHandle, resolvePeerSessions } from "./peer-directory.js";
7
9
  import { escapeAttributeValue, escapeEnvelopeTag, isObserverTaskId, OBSERVER_SENDMESSAGE_SENDER_REFUSAL, OBSERVER_SENDMESSAGE_TARGET_REFUSAL, } from "./observer.js";
8
10
  import { inlineUntrusted } from "../core/untrusted-text.js";
11
+ import { neutralizePeerBody } from "./cross-session-envelope.js";
9
12
  import { SUBAGENT_RESUME_CAP, SubagentRetainLedger, getSessionRetainLedger } from "./retain-ledger.js";
10
13
  import { createSubagentResume } from "./subagent.js";
11
14
  import { appendHopToken, judgePeerAdmission, peerAxisToken, resolvePeerAdmissionConfig, PEER_MESSAGE_NOTICE, } from "./peer-admission.js";
@@ -24,10 +27,11 @@ export function clipSendMessageSummary(raw) {
24
27
  const TEAMMATE_MESSAGE_TAG = "teammate-message";
25
28
  function frameTeammateMessage(args) {
26
29
  const summaryAttr = args.summary !== undefined ? ` summary="${escapeAttributeValue(args.summary)}"` : "";
27
- const body = escapeEnvelopeTag(TEAMMATE_MESSAGE_TAG, args.text);
30
+ const body = escapeEnvelopeTag(TEAMMATE_MESSAGE_TAG, neutralizePeerBody(args.text));
28
31
  return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${escapeAttributeValue(args.from)}"${summaryAttr}>\n${body}\n</${TEAMMATE_MESSAGE_TAG}>`;
29
32
  }
30
33
  const REVIVE_LEASE_TTL_MS = 5 * 60_000;
34
+ const PEER_REFUSAL_TEXT_MAX = 1200;
31
35
  const sendMessageTargetLanes = new Map();
32
36
  function withTargetLane(key, fn) {
33
37
  const prev = sendMessageTargetLanes.get(key) ?? Promise.resolve();
@@ -46,21 +50,44 @@ function targetLaneKey(scope, targetId) {
46
50
  const OPERATOR_CONTINUATION_CTX = Symbol("sema.operator_continuation");
47
51
  export function createSendMessageTool(opts) {
48
52
  const tier3Capable = opts.agentStore !== undefined && opts.mailbox !== undefined && opts.reviveSpawn !== undefined;
53
+ const peerLane = (() => {
54
+ if (opts.peerDirectory === undefined)
55
+ return { active: false };
56
+ if (opts.mailbox === undefined)
57
+ return { active: false, reason: "a peer-session directory is wired but no mailbox store is (the lane parks messages in the recipient's durable session box)" };
58
+ const verdict = mailboxCrossProcessMountVerdict(opts.mailbox);
59
+ return verdict.ok ? { active: true } : { active: false, reason: verdict.reason };
60
+ })();
61
+ const retrievalMounted = opts.retrievalToolMounted;
62
+ const retrievalHedge = retrievalMounted === undefined ? " (where that tool is mounted)" : "";
63
+ const RETRIEVE_NOW = retrievalMounted === false
64
+ ? "its result cannot be collected by this run (no TaskOutput is mounted beside this tool) — do not wait for it, and say so in your answer if the result matters"
65
+ : `retrieve its result NOW with TaskOutput(task_id, block: true)${retrievalHedge} rather than ending your turn`;
66
+ const RETRIEVE_LATER = retrievalMounted === false
67
+ ? "its result cannot be collected by this run (no TaskOutput is mounted beside this tool)"
68
+ : "retrieve its status and result with TaskOutput(task_id) where mounted";
69
+ const WAIT_NOW = retrievalMounted === false
70
+ ? "do not wait for it — this face has no TaskOutput to block on, and"
71
+ : `wait for it NOW with TaskOutput(task_id, block: true)${retrievalHedge} —`;
72
+ const CHECK_LATER = retrievalMounted === false
73
+ ? "its completion cannot be checked from this face (no TaskOutput is mounted beside this tool)"
74
+ : "check for its completion with TaskOutput(task_id) where mounted";
75
+ const capitalize = (text) => `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
49
76
  const completionMode = opts.oneShot === true ? "one_shot" : (opts.notificationWired ?? opts.notify !== undefined) ? "notified" : "silent";
50
77
  const notifyWired = completionMode === "notified";
51
78
  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";
79
+ ? `this is a ONE-SHOT submission — there is no later turn for a completion notification to land in, so ${RETRIEVE_NOW}`
80
+ : `its completion is NOT announced on this mount — ${RETRIEVE_LATER}`;
54
81
  const AWAIT_COMPLETION = notifyWired
55
82
  ? "Wait for its completion notification"
56
83
  : 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";
84
+ ? `${capitalize(WAIT_NOW)} this one-shot submission ends with this turn`
85
+ : capitalize(CHECK_LATER);
59
86
  const awaitCompletion = notifyWired
60
87
  ? "wait for its completion notification"
61
88
  : 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";
89
+ ? `${WAIT_NOW} this one-shot submission ends with this turn`
90
+ : CHECK_LATER;
64
91
  const sendMessagePins = new Map();
65
92
  const pinGuard = (targetId, targetName, rung, to) => {
66
93
  if (rung === "other")
@@ -104,12 +131,19 @@ export function createSendMessageTool(opts) {
104
131
  (notifyWired
105
132
  ? `You will be notified automatically when it completes — prefer ending your turn; do not poll. `
106
133
  : 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. `) +
134
+ ? `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${retrievalMounted === false
135
+ ? "; a continued agent's result cannot be collected by this run (no TaskOutput is mounted beside this tool) say so if the result matters"
136
+ : ` — wait actively with TaskOutput(task_id, block: true)${retrievalHedge} when you need a continued agent's result`}. `
137
+ : `A finished agent is NOT announced on this mount${retrievalMounted === false
138
+ ? ", and its result cannot be collected by this run (no TaskOutput is mounted beside this tool) — do not end your turn to wait for a notification that never comes"
139
+ : " — check on it with TaskOutput(task_id) where mounted rather than ending your turn to wait for a notification that never comes"}. `) +
109
140
  (tier3Capable
110
141
  ? `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.`
111
142
  : `Continuing a finished agent requires the run to retain sub-agent sessions; when the session was not ` +
112
- `retained (or is no longer held) you get an honest error and should launch a new agent with the needed context instead.`),
143
+ `retained (or is no longer held) you get an honest error and should launch a new agent with the needed context instead.`) +
144
+ (peerLane.active
145
+ ? ` Peer SESSIONS — other sessions of this engine for the same user${opts.peerListingMounted === false ? "" : opts.peerListingMounted === true ? " (list them with ListAgents)" : " (list them with ListAgents where that tool is mounted)"} — are addressed by name, by \`name [ref]\` when a name is ambiguous, or by their \`session.<id>\` address. A message to a peer is parked durably in its session box and read at its next turn boundary (an offline peer reads it when it next runs); the receipt confirms the box accepted it, not that the peer acted on it.`
146
+ : ""),
113
147
  parameters: Type.Object({
114
148
  to: Type.String({ description: 'Recipient: the agent\'s name, or its task_id (a…) returned by the Agent tool with run_in_background. "main" is reserved for the spawning conversation.' }),
115
149
  message: Type.String({ description: "The follow-up request. The agent continues from its full prior context." }),
@@ -159,9 +193,9 @@ export function createSendMessageTool(opts) {
159
193
  return { content: OBSERVER_SENDMESSAGE_TARGET_REFUSAL, details: { error: "observer_target", to }, isError: true };
160
194
  }
161
195
  const admissionConfig = resolvePeerAdmissionConfig(opts.admission);
162
- const guardScope = ctx.principal ?? opts.scope;
196
+ const guardScope = ctx.principal ?? opts.scope ?? opts.peerSelfSession?.scope;
163
197
  const selfRef = ctx.peerSelfRef ?? opts.peerSelf;
164
- const directMountSessionId = ctx.sessionId ?? opts.sessionId;
198
+ const directMountSessionId = ctx.sessionId ?? opts.sessionId ?? opts.peerSelfSession?.sessionId;
165
199
  const directMountTaskId = ctx.taskId ?? opts.owner;
166
200
  const senderKey = selfRef?.current.key ??
167
201
  (directMountSessionId !== undefined
@@ -183,6 +217,125 @@ export function createSendMessageTool(opts) {
183
217
  : `this message's forwarding chain is too long (runaway relay) — stop relaying it; act on it or drop it.`;
184
218
  return { content: `Message not sent: ${text}`, details: { error: reason, code: reason, to }, isError: true };
185
219
  };
220
+ const peerRefusal = (code, text, extra) => {
221
+ const verdict = { ok: false, code, severity: peerSendVerdictSeverity(code), message: text };
222
+ return { content: `Message not sent: ${text.slice(0, PEER_REFUSAL_TEXT_MAX)}`, details: { error: code, code, to, verdict, ...extra }, isError: true };
223
+ };
224
+ const peerSelfSessionId = opts.peerSelfSession?.sessionId ?? directMountSessionId;
225
+ const peerScope = guardScope ?? opts.peerSelfSession?.scope;
226
+ let ownRowName;
227
+ const resolvePeerTarget = async () => {
228
+ try {
229
+ const rows = await opts.peerDirectory.listPeerSessions({ scope: peerScope ?? "" });
230
+ if (peerSelfSessionId !== undefined)
231
+ ownRowName = rows.find((r) => r.sessionId.toLowerCase() === peerSelfSessionId.toLowerCase())?.name;
232
+ return resolvePeerSessions(rows, to, peerSelfSessionId !== undefined ? { sessionId: peerSelfSessionId } : undefined);
233
+ }
234
+ catch (e) {
235
+ return { status: "directory_fault", message: e instanceof Error ? e.message : String(e) };
236
+ }
237
+ };
238
+ const deliverToPeerSession = async (c) => {
239
+ const record = c.record;
240
+ const handle = peerSessionBoxHandle(record.sessionId);
241
+ const peerLabel = `peer session "${inlineUntrusted(record.name, 64)}" (${handle})`;
242
+ if (peerScope === undefined)
243
+ return peerRefusal("peer_send.other", `this mount has no scope axis, so ${peerLabel}'s box cannot be addressed.`);
244
+ if (record.inboundPosture === "unavailable") {
245
+ return peerRefusal("peer_send.recipient_refuses", `${peerLabel} advertises that it does not accept cross-session messages (its crossSessionInbound is "refuse"). Nothing was parked.`);
246
+ }
247
+ const recipientKey = peerAxisToken(peerScope, "s", record.sessionId);
248
+ const storedContent = `[${summary}] ${clipCarrierMessage(message)}`;
249
+ const sendVerdict = judgePeerAdmission(peerScope, recipientKey, { senderKey, body: storedContent, prospectiveChain, ownTokens: [recipientKey] }, admissionConfig, undefined, "send");
250
+ if (!sendVerdict.ok)
251
+ return admissionRefusal(sendVerdict.reason, peerLabel);
252
+ let boxFull = false;
253
+ try {
254
+ boxFull = (await opts.mailbox.peekCount(peerScope, handle)) >= admissionConfig.maxQueuedPeerMessages;
255
+ }
256
+ catch {
257
+ }
258
+ if (boxFull) {
259
+ return { content: `Message not sent: ${inlineUntrusted(peerLabel, 200)}'s session box is at its queued-message limit (${admissionConfig.maxQueuedPeerMessages.toFixed(0)}) — the message was NOT queued; it drains the box at its next turn. Resend later. ${DEDUP_RETRY_NOTE.slice(0, 200)}`, details: { error: "queue_full", code: "queue_full", to }, isError: true };
260
+ }
261
+ const modeClass = opts.peerSelfSession?.modeClass?.();
262
+ const peerMeta = {
263
+ ...(peerSelfSessionId !== undefined ? { fromSession: peerSelfSessionId } : {}),
264
+ ...(modeClass === "bypass" || modeClass === "prompting" ? { fromMode: modeClass } : {}),
265
+ ...(senderKey !== undefined ? { senderKey } : {}),
266
+ kind: "peer_message",
267
+ };
268
+ const now = Date.now();
269
+ let seq;
270
+ try {
271
+ seq = await opts.mailbox.append(peerScope, handle, { from: ownRowName ?? opts.peerSelfSession?.name ?? senderLabel, content: storedContent, sentAt: now, hopChain: prospectiveChain, peerMeta });
272
+ }
273
+ catch (e) {
274
+ if (e?.code === MAILBOX_TOMBSTONED_RECIPIENT_CODE) {
275
+ return peerRefusal("peer_send.invalid_target", `${peerLabel} is being deleted — its box no longer accepts messages. Nothing was parked; do not retry.`);
276
+ }
277
+ return peerRefusal("peer_send.other", `the session box refused the message (${e instanceof Error ? e.message : String(e)}) — nothing was parked. ${DEDUP_RETRY_NOTE}`);
278
+ }
279
+ let rowRestored = "present";
280
+ try {
281
+ const rowsNow = await opts.peerDirectory.listPeerSessions({ scope: peerScope });
282
+ const nowRow = rowsNow.find((r) => r.sessionId.toLowerCase() === record.sessionId.toLowerCase());
283
+ if (nowRow?.liveness === "deleted")
284
+ rowRestored = "deleted_meanwhile";
285
+ else if (nowRow === undefined) {
286
+ rowRestored = opts.peerDirectory.restorePeerSession !== undefined && (await opts.peerDirectory.restorePeerSession(record)) ? "restored" : "unrestorable";
287
+ }
288
+ }
289
+ catch {
290
+ }
291
+ const rowNote = rowRestored === "unrestorable"
292
+ ? " Note: the peer's directory row disappeared while this message was being parked and this directory cannot restore it — the message is safe in the box (it drains by session id), but the name may not resolve until that session next registers; its session.<id> address still does."
293
+ : rowRestored === "deleted_meanwhile"
294
+ ? " Note: that session was DELETED while this message was being parked — its box is being retired and this message will not be read; do not resend to it."
295
+ : "";
296
+ const offline = record.liveness !== "live";
297
+ const verdict = { ok: true, disposition: offline ? "parked_offline" : "queued", via: "store" };
298
+ return {
299
+ content: offline
300
+ ? `Message parked for ${peerLabel}: that session is offline, so the message was parked durably in its session box (seq ${seq}) and will be delivered when it next runs — subject to the deployment's mailbox retirement policy, which does not notify you. Continue with your task; do not resend the same content.${rowNote}`
301
+ : `Message queued for ${peerLabel} (seq ${seq}): it is parked durably in that session's box and will be read at its next turn boundary. This receipt confirms acceptance into the box, not the peer's judgment of the message. Continue with your task; do not resend the same content.${rowNote}`,
302
+ details: { type: "send-message", status: offline ? "peer_parked_offline" : "peer_queued", to, address: handle, seq, verdict, ...(rowRestored !== "present" ? { directoryRow: rowRestored } : {}) },
303
+ };
304
+ };
305
+ const settlePeerResolution = async (r) => {
306
+ switch (r.status) {
307
+ case "directory_fault":
308
+ return peerRefusal("peer_send.other", `the peer-session directory could not be read (${r.message}) — nothing was sent.`);
309
+ case "not_found":
310
+ return undefined;
311
+ case "self":
312
+ return peerRefusal("peer_send.invalid_target", `"${inlineUntrusted(to, 120)}" is this session — a session cannot send a cross-session message to itself.`);
313
+ case "deleted":
314
+ return peerRefusal("peer_send.invalid_target", `peer session "${inlineUntrusted(r.candidate.record.name, 64)}" was deleted — its address no longer accepts messages. Nothing was parked.`);
315
+ case "stale_ref":
316
+ return peerRefusal("peer_send.invalid_target", `the ref [${r.ref}] no longer resolves (the peer set changed since you listed it) — re-run ListAgents and re-send with a current ref.`);
317
+ case "ambiguous":
318
+ return { content: `Message not sent: ${inlineUntrusted(r.message, 1000)}`, details: { error: "ambiguous", to, candidates: r.candidates.map((c) => ({ name: c.record.name, ref: c.ref, address: peerSessionBoxHandle(c.record.sessionId), liveness: c.record.liveness })) }, isError: true };
319
+ case "found": {
320
+ const guard = pinGuard(peerSessionBoxHandle(r.candidate.record.sessionId), r.candidate.record.name, r.rung === "name" ? "name" : "ref", to);
321
+ if (guard !== undefined)
322
+ return guard;
323
+ return await withTargetLane(targetLaneKey(peerScope, peerSessionBoxHandle(r.candidate.record.sessionId)), () => deliverToPeerSession(r.candidate));
324
+ }
325
+ default: {
326
+ const _exhaustive = r;
327
+ void _exhaustive;
328
+ return undefined;
329
+ }
330
+ }
331
+ };
332
+ if (parsePeerSessionAddress(to) !== undefined) {
333
+ if (!peerLane.active) {
334
+ return peerRefusal("peer_send.invalid_target", peerLane.reason !== undefined ? `cross-session addressing is unavailable on this mount — ${peerLane.reason}. Nothing was sent.` : `cross-session addressing is not mounted here (no peer-session directory), so "${inlineUntrusted(to, 120)}" cannot be delivered.`);
335
+ }
336
+ const settled = await settlePeerResolution(await resolvePeerTarget());
337
+ return settled ?? peerRefusal("peer_send.invalid_target", `no session is registered at "${inlineUntrusted(to, 120)}" — nothing was parked (a message is only ever parked for a session that exists or existed).`);
338
+ }
186
339
  if (normalizeAgentName(to) === "main") {
187
340
  if (opts.uplink && senderId !== undefined) {
188
341
  const uplinkIdentity = opts.uplinkRecipient?.current;
@@ -191,15 +344,19 @@ export function createSendMessageTool(opts) {
191
344
  if (!verdict.ok)
192
345
  return admissionRefusal(verdict.reason, "main");
193
346
  }
347
+ const uplinkBody = 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;
348
+ const uplinkSeq = ++uplinkSeqGlobal;
194
349
  try {
195
350
  opts.uplink({
196
351
  task_id: senderId,
197
352
  task_type: "background_agent",
198
353
  status: "event",
199
354
  summary: `message from ${senderLabel}: ${summary}`,
200
- 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}`,
201
- seq: ++uplinkSeqGlobal,
355
+ result: `${uplinkBody}\n\n${PEER_MESSAGE_NOTICE}`,
356
+ seq: uplinkSeq,
202
357
  peer: { hopChain: prospectiveChain },
358
+ agentMessage: { from: senderLabel, body: uplinkBody },
359
+ _sema_provenance: { kind: "agent_message", from: senderLabel, taskId: senderId, seq: uplinkSeq },
203
360
  }, { priority: "next" });
204
361
  }
205
362
  catch (e) {
@@ -207,7 +364,7 @@ export function createSendMessageTool(opts) {
207
364
  }
208
365
  return {
209
366
  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.`,
210
- details: { type: "send-message", status: "uplinked", to: "main", seq: uplinkSeqGlobal },
367
+ details: { type: "send-message", status: "uplinked", to: "main", seq: uplinkSeq },
211
368
  };
212
369
  }
213
370
  if (!senderIsChild) {
@@ -594,6 +751,14 @@ export function createSendMessageTool(opts) {
594
751
  const labels = parentAccess !== undefined
595
752
  ? [...ownLabels, ...opts.registry.runningBackgroundAgentLabels(parentAccess).filter((l) => !ownLabels.includes(l))]
596
753
  : ownLabels;
754
+ if (peerLane.active) {
755
+ const settled = await settlePeerResolution(await resolvePeerTarget());
756
+ if (settled !== undefined)
757
+ return settled;
758
+ }
759
+ else if (peerLane.reason !== undefined) {
760
+ return peerRefusal("peer_send.other", `no agent matches "${inlineUntrusted(to, 120)}", and the cross-session lane that could reach a peer session by that name is unavailable on this mount — ${peerLane.reason}.`);
761
+ }
597
762
  if (DURABLE_AGENT_HANDLE_RE.test(to)) {
598
763
  return {
599
764
  content: `Message not sent: no transcript found for agent ${to} — launch a new agent with the needed context instead.`,
@@ -41,6 +41,17 @@ export interface SubagentEditedFile {
41
41
  }
42
42
  /** Keep the last N tool steps (CC's tail is ~this deep; older steps are rarely load-bearing for resume). */
43
43
  export declare const STEP_CAP = 10;
44
+ /** Hard-bound a line to `max` UTF-16 units — a pure prefix, no marker appended. When the cut would split a
45
+ * redaction marker, it moves back to the marker's start (the bound holds, and the output never shows a
46
+ * marker fragment); when it would split a surrogate pair, it moves back one unit (never a lone surrogate). */
47
+ export declare function cutAt(line: string, max: number): string;
48
+ /**
49
+ * Redact, THEN bound — the order every display cut in this module (and the runner's `steering_injected`
50
+ * previews) uses. Exported so a preview minted elsewhere gets the same marker-aware cut rather than a
51
+ * raw `slice` that can leave a half marker at the bound. Keeps newlines: a multi-line body is the
52
+ * caller's shape; only the bound and the redaction are this function's.
53
+ */
54
+ export declare function redactThenCut(s: string, max: number): string;
44
55
  /** The tool's primary argument as a single short line (best-effort; unknown shapes → a compact JSON head). */
45
56
  export declare function extractTarget(args: unknown): string;
46
57
  /**