@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.
- package/CHANGELOG.md +65 -0
- package/dist/agents/cross-session-envelope.d.ts +145 -0
- package/dist/agents/cross-session-envelope.js +195 -0
- package/dist/agents/cross-session-judge.d.ts +119 -0
- package/dist/agents/cross-session-judge.js +184 -0
- package/dist/agents/cross-session-ref.d.ts +52 -0
- package/dist/agents/cross-session-ref.js +64 -0
- package/dist/agents/list-agents-tool.d.ts +55 -0
- package/dist/agents/list-agents-tool.js +94 -0
- package/dist/agents/peer-admission.d.ts +17 -1
- package/dist/agents/peer-admission.js +19 -2
- package/dist/agents/peer-directory.d.ts +208 -0
- package/dist/agents/peer-directory.js +272 -0
- package/dist/agents/peer-session-drain.d.ts +159 -0
- package/dist/agents/peer-session-drain.js +245 -0
- package/dist/agents/send-message-tool.d.ts +44 -0
- package/dist/agents/send-message-tool.js +181 -16
- package/dist/agents/subagent-steps.d.ts +11 -0
- package/dist/agents/subagent-steps.js +27 -4
- package/dist/core/auto-mode-arming.d.ts +11 -0
- package/dist/core/auto-mode-arming.js +7 -1
- package/dist/core/auto-mode-prompt.d.ts +5 -0
- package/dist/core/auto-mode-prompt.js +2 -1
- package/dist/core/auto-mode-rebuild.d.ts +2 -1
- package/dist/core/auto-mode-rebuild.js +2 -0
- package/dist/core/checkpoint-store.d.ts +203 -3
- package/dist/core/checkpoint-store.js +60 -19
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +6 -0
- package/dist/core/hooks.d.ts +15 -8
- package/dist/core/hooks.js +6 -3
- package/dist/core/mailbox-store.d.ts +89 -2
- package/dist/core/mailbox-store.js +77 -2
- package/dist/core/permission-rule-consent.d.ts +72 -23
- package/dist/core/permission-rule-consent.js +115 -26
- package/dist/core/permission-rule-model.d.ts +254 -51
- package/dist/core/permission-rule-model.js +316 -55
- package/dist/core/permission-rule-org.js +13 -6
- package/dist/core/remote-env.d.ts +8 -1
- package/dist/core/runner/assemble-result.js +2 -1
- package/dist/core/runner/prepare-task.d.ts +59 -1
- package/dist/core/runner/prepare-task.js +414 -149
- package/dist/core/runner/prepare-workspace-restore.d.ts +6 -1
- package/dist/core/runner/prepare-workspace-restore.js +2 -1
- package/dist/core/runner/runtask.js +16 -5
- package/dist/core/runner/tool-output-projection.js +1 -0
- package/dist/core/store-contracts/mailbox-store-contract.d.ts +23 -0
- package/dist/core/store-contracts/mailbox-store-contract.js +157 -1
- package/dist/core/task-notification.d.ts +93 -5
- package/dist/core/task-notification.js +31 -4
- package/dist/core/tool-policy.d.ts +11 -0
- package/dist/core/types.d.ts +155 -21
- package/dist/core/untrusted-text.js +17 -1
- package/dist/core/wiring-manifest.d.ts +21 -0
- package/dist/core/wiring-manifest.js +1 -0
- package/dist/index.d.ts +14 -5
- package/dist/index.js +13 -4
- package/dist/stores/cc/mailbox-store.d.ts +1 -1
- package/dist/stores/cc/mailbox-store.js +13 -0
- package/dist/stores/file/adoption/marker.d.ts +1 -1
- package/dist/stores/file/mailbox-store.d.ts +57 -0
- package/dist/stores/file/mailbox-store.js +369 -18
- package/package.json +1 -1
- 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
|
-
?
|
|
53
|
-
:
|
|
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
|
-
?
|
|
58
|
-
:
|
|
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
|
-
?
|
|
63
|
-
:
|
|
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
|
|
108
|
-
|
|
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: `${
|
|
201
|
-
seq:
|
|
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:
|
|
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
|
/**
|