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