@sema-agent/core 7.1.0 → 7.2.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 +36 -0
- package/dist/agents/cross-session-envelope.d.ts +138 -0
- package/dist/agents/cross-session-envelope.js +191 -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/send-message-tool.d.ts +13 -0
- package/dist/agents/send-message-tool.js +36 -12
- package/dist/core/checkpoint-store.d.ts +189 -3
- package/dist/core/checkpoint-store.js +56 -16
- package/dist/core/hooks.d.ts +15 -8
- package/dist/core/hooks.js +6 -3
- 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 +245 -51
- package/dist/core/permission-rule-model.js +312 -54
- 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 +39 -1
- package/dist/core/runner/prepare-task.js +278 -113
- 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 +13 -3
- package/dist/core/task-notification.d.ts +64 -5
- package/dist/core/task-notification.js +25 -4
- package/dist/core/tool-policy.d.ts +11 -0
- package/dist/core/types.d.ts +23 -0
- package/dist/core/untrusted-text.js +17 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -2
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +125 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { isPermissionModeClass } from "./cross-session-envelope.js";
|
|
2
|
+
export const CROSS_SESSION_INBOUND_SETTINGS = Object.freeze(["accept", "hold", "refuse"]);
|
|
3
|
+
export function isCrossSessionInboundSetting(value) {
|
|
4
|
+
return typeof value === "string" && CROSS_SESSION_INBOUND_SETTINGS.includes(value);
|
|
5
|
+
}
|
|
6
|
+
const RANK = { accept: 0, hold: 1, refuse: 2 };
|
|
7
|
+
function readLayer(v) {
|
|
8
|
+
if (v === undefined || v === null || v === "default")
|
|
9
|
+
return undefined;
|
|
10
|
+
return isCrossSessionInboundSetting(v) ? v : "invalid";
|
|
11
|
+
}
|
|
12
|
+
export function resolveCrossSessionInboundSetting(layers) {
|
|
13
|
+
const invalidLayers = [];
|
|
14
|
+
let value;
|
|
15
|
+
let decidedBy;
|
|
16
|
+
for (const [name, raw] of [
|
|
17
|
+
["managed", layers.managed],
|
|
18
|
+
["user", layers.user],
|
|
19
|
+
]) {
|
|
20
|
+
const v = readLayer(raw);
|
|
21
|
+
if (v === "invalid") {
|
|
22
|
+
invalidLayers.push(name);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (v !== undefined && value === undefined) {
|
|
26
|
+
value = v;
|
|
27
|
+
decidedBy = name;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const raw of layers.repo ?? []) {
|
|
31
|
+
const v = readLayer(raw);
|
|
32
|
+
if (v === "invalid") {
|
|
33
|
+
invalidLayers.push("repo");
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (v === undefined)
|
|
37
|
+
continue;
|
|
38
|
+
if (RANK[v] > RANK[value ?? "accept"]) {
|
|
39
|
+
value = v;
|
|
40
|
+
decidedBy = "repo";
|
|
41
|
+
}
|
|
42
|
+
else if (v !== "accept" && value !== undefined && RANK[v] === RANK[value] && decidedBy !== "managed") {
|
|
43
|
+
decidedBy = "repo";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (invalidLayers.length > 0 && RANK[value ?? "accept"] < RANK.hold) {
|
|
47
|
+
value = "hold";
|
|
48
|
+
decidedBy = "invalid";
|
|
49
|
+
}
|
|
50
|
+
return { ...(value !== undefined ? { value } : {}), ...(decidedBy !== undefined ? { decidedBy } : {}), invalidLayers };
|
|
51
|
+
}
|
|
52
|
+
export const CROSS_SESSION_HOLD_CAUSES = Object.freeze([
|
|
53
|
+
"mode-mismatch",
|
|
54
|
+
"no-mode-asserted",
|
|
55
|
+
"explicit-setting",
|
|
56
|
+
"managed-setting",
|
|
57
|
+
"repo-setting",
|
|
58
|
+
"invalid-setting",
|
|
59
|
+
"bypass-default",
|
|
60
|
+
"mode-unknown",
|
|
61
|
+
"invalid-mode-attestation",
|
|
62
|
+
]);
|
|
63
|
+
export function describeCrossSessionHoldCause(cause) {
|
|
64
|
+
switch (cause) {
|
|
65
|
+
case "mode-mismatch":
|
|
66
|
+
return "The sending session's permission mode class doesn't match this session's, so it wasn't delivered automatically.";
|
|
67
|
+
case "no-mode-asserted":
|
|
68
|
+
return "The sender did not attest its permission mode, and this session bypasses permission prompts.";
|
|
69
|
+
case "explicit-setting":
|
|
70
|
+
return 'Your "crossSessionInbound" setting is "hold".';
|
|
71
|
+
case "managed-setting":
|
|
72
|
+
return `Your organization's managed settings set "crossSessionInbound" to "hold".`;
|
|
73
|
+
case "repo-setting":
|
|
74
|
+
return `This repository's settings set "crossSessionInbound" to "hold" (your own "accept" cannot override a repo tightening).`;
|
|
75
|
+
case "invalid-setting":
|
|
76
|
+
return 'A settings file has an unrecognized "crossSessionInbound" value (see the settings warning), so messages are held while it is present.';
|
|
77
|
+
case "bypass-default":
|
|
78
|
+
return "This session is not prompting for permissions.";
|
|
79
|
+
case "mode-unknown":
|
|
80
|
+
return "This session's permission mode could not be determined.";
|
|
81
|
+
case "invalid-mode-attestation":
|
|
82
|
+
return "The sender attested an unrecognized permission mode, so the message is held for your review.";
|
|
83
|
+
default: {
|
|
84
|
+
const _exhaustive = cause;
|
|
85
|
+
void _exhaustive;
|
|
86
|
+
throw new Error(`unreachable hold cause ${String(cause)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function describeUnknown(value) {
|
|
91
|
+
try {
|
|
92
|
+
const text = typeof value === "string" ? JSON.stringify(value) : typeof value === "bigint" ? `${value}n` : typeof value === "object" && value !== null ? JSON.stringify(value) : String(value);
|
|
93
|
+
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return `[unrenderable ${typeof value}]`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function holdFor(cause, warning) {
|
|
100
|
+
return { verdict: "hold", cause, message: describeCrossSessionHoldCause(cause), ...(warning !== undefined ? { warning } : {}) };
|
|
101
|
+
}
|
|
102
|
+
function causeOf(decidedBy) {
|
|
103
|
+
switch (decidedBy) {
|
|
104
|
+
case "managed":
|
|
105
|
+
return "managed-setting";
|
|
106
|
+
case "repo":
|
|
107
|
+
return "repo-setting";
|
|
108
|
+
case "invalid":
|
|
109
|
+
return "invalid-setting";
|
|
110
|
+
case "user":
|
|
111
|
+
case undefined:
|
|
112
|
+
return "explicit-setting";
|
|
113
|
+
default: {
|
|
114
|
+
const _exhaustive = decidedBy;
|
|
115
|
+
void _exhaustive;
|
|
116
|
+
throw new Error(`unreachable setting source ${String(decidedBy)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function judgeCrossSessionInbound(input) {
|
|
121
|
+
const { setting, selfModeClass, sender } = input;
|
|
122
|
+
if (setting.value !== undefined) {
|
|
123
|
+
switch (setting.value) {
|
|
124
|
+
case "accept":
|
|
125
|
+
return { verdict: "deliver" };
|
|
126
|
+
case "refuse":
|
|
127
|
+
return { verdict: "refuse", cause: "opt-out" };
|
|
128
|
+
case "hold": {
|
|
129
|
+
const cause = causeOf(setting.decidedBy);
|
|
130
|
+
return holdFor(cause, cause === "invalid-setting" ? `unrecognized "crossSessionInbound" value in: ${setting.invalidLayers.join(", ")}` : undefined);
|
|
131
|
+
}
|
|
132
|
+
default: {
|
|
133
|
+
const _exhaustive = setting.value;
|
|
134
|
+
void _exhaustive;
|
|
135
|
+
throw new Error(`unreachable setting value ${String(setting.value)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (sender?.selfSent === true)
|
|
140
|
+
return { verdict: "deliver" };
|
|
141
|
+
if (selfModeClass === "unknown")
|
|
142
|
+
return holdFor("mode-unknown");
|
|
143
|
+
if (sender === undefined)
|
|
144
|
+
return selfModeClass === "bypass" ? holdFor("bypass-default") : { verdict: "deliver" };
|
|
145
|
+
if (sender.fromMode !== undefined) {
|
|
146
|
+
if (!isPermissionModeClass(sender.fromMode)) {
|
|
147
|
+
return holdFor("invalid-mode-attestation", `sender attested from-mode=${describeUnknown(sender.fromMode)} (expected bypass|prompting)`);
|
|
148
|
+
}
|
|
149
|
+
return sender.fromMode === selfModeClass ? { verdict: "deliver" } : holdFor("mode-mismatch");
|
|
150
|
+
}
|
|
151
|
+
return selfModeClass === "bypass" ? holdFor("no-mode-asserted") : { verdict: "deliver" };
|
|
152
|
+
}
|
|
153
|
+
export function foldPermissionModeClass(askEffective) {
|
|
154
|
+
switch (askEffective) {
|
|
155
|
+
case "auto_allow":
|
|
156
|
+
return "bypass";
|
|
157
|
+
case "human_reachable":
|
|
158
|
+
case "park_only":
|
|
159
|
+
case "auto_deny":
|
|
160
|
+
return "prompting";
|
|
161
|
+
case "unresolved":
|
|
162
|
+
return "unknown";
|
|
163
|
+
default: {
|
|
164
|
+
const _exhaustive = askEffective;
|
|
165
|
+
void _exhaustive;
|
|
166
|
+
throw new Error(`unreachable ask effective ${String(askEffective)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export const PEER_SEND_VERDICT_CODES = Object.freeze([
|
|
171
|
+
"peer_send.stale_socket",
|
|
172
|
+
"peer_send.socket_busy",
|
|
173
|
+
"peer_send.too_large",
|
|
174
|
+
"peer_send.invalid_target",
|
|
175
|
+
"peer_send.timeout",
|
|
176
|
+
"peer_send.recipient_refuses",
|
|
177
|
+
"peer_send.not_reachable",
|
|
178
|
+
"peer_send.subscription_cap",
|
|
179
|
+
"peer_send.send_uncertain",
|
|
180
|
+
"peer_send.other",
|
|
181
|
+
]);
|
|
182
|
+
export function peerSendVerdictSeverity(code) {
|
|
183
|
+
return code === "peer_send.send_uncertain" || code === "peer_send.recipient_refuses" || code === "peer_send.subscription_cap" ? "degraded" : "error";
|
|
184
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** CC `aa` / `bmn`: a ref is 6..12 hex characters — the shortest prefix of `sha256(kind:id)` that is
|
|
2
|
+
* unique against its sorted neighbours in the LISTING that minted it. */
|
|
3
|
+
export declare const PEER_REF_MIN = 6;
|
|
4
|
+
export declare const PEER_REF_MAX = 12;
|
|
5
|
+
/** CC `Mz`. */
|
|
6
|
+
export declare const PEER_REF_RE: RegExp;
|
|
7
|
+
export interface PeerRefEntry {
|
|
8
|
+
/** The identity kind (`session`, `agent`, …): part of the hash input so two kinds with the same id
|
|
9
|
+
* never share a ref. */
|
|
10
|
+
kind: string;
|
|
11
|
+
/** The durable id (a `session.<id>` box handle, an a* agent handle, …). */
|
|
12
|
+
id: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* CC `K8` transcribed on sha256: mint the ref of EVERY entry of one listing. Refs are minted by the
|
|
16
|
+
* listing side and are valid only against that listing (a ref the model did not just read from a
|
|
17
|
+
* listing or a disambiguation error will not resolve). The optional `self` entry joins the
|
|
18
|
+
* disambiguation set (so no ref collides with the caller's own identity) but is not returned.
|
|
19
|
+
* The hash choice is local to this engine: a ref never crosses a process boundary, so the value
|
|
20
|
+
* domain (`[0-9a-f]{6,12}`) is what interoperates, not the digest.
|
|
21
|
+
*/
|
|
22
|
+
export declare function mintPeerRef<T extends PeerRefEntry>(entries: readonly T[], self?: PeerRefEntry): Array<T & {
|
|
23
|
+
ref: string;
|
|
24
|
+
}>;
|
|
25
|
+
/** CC `qN` — the display token. */
|
|
26
|
+
export declare function formatPeerNameRef(name: string, ref: string): string;
|
|
27
|
+
/** CC `V8`/`DO` — split a `name [ref]` token; `undefined` when no ref suffix is present. */
|
|
28
|
+
export declare function parsePeerNameRef(text: string): {
|
|
29
|
+
name: string;
|
|
30
|
+
ref: string;
|
|
31
|
+
} | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* CC `dr` — the MATCHING key of a peer/session name: NFKC → strip control/format code points → trim →
|
|
34
|
+
* lower-case → collapse whitespace runs to `-`. Every terminal resolves and compares names on this
|
|
35
|
+
* key (a display name is never the key).
|
|
36
|
+
*/
|
|
37
|
+
export declare function normalizePeerName(name: string): string;
|
|
38
|
+
/** The box-address prefixes of design/385 (`session.<id>` per-session box, `principal.<…>` the
|
|
39
|
+
* cross-principal inbox family). Reserved: a NAME may never spell an address. */
|
|
40
|
+
export declare const PEER_ADDRESS_PREFIXES: readonly string[];
|
|
41
|
+
/**
|
|
42
|
+
* design/385 §2.2 namespace protection (CC `qd` in spirit): a candidate spawn/session name is refused
|
|
43
|
+
* when its normalized key would shadow an explicit address — a reserved box prefix, a transport
|
|
44
|
+
* scheme (`uds:` / `bridge:` / `did:`), a socket path, an `@`-bearing team form, or the `*`
|
|
45
|
+
* broadcast token. Returns the reason, or `undefined` when the name is admissible. Registration-side
|
|
46
|
+
* check (loud refusal at write time); the resolution ladder ranks explicit addresses first regardless.
|
|
47
|
+
*
|
|
48
|
+
* The socket-path arm is judged on the RAW name AND on its normalized key (CC `QHe` runs its
|
|
49
|
+
* classifier over both): a name that is a socket path in either spelling is refused, since the
|
|
50
|
+
* resolution ladder would read the raw spelling as an address.
|
|
51
|
+
*/
|
|
52
|
+
export declare function reservedPeerNameReason(name: string): "empty" | "address_prefix" | "transport_scheme" | "socket_path" | "at_sign" | "wildcard" | undefined;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const PEER_REF_MIN = 6;
|
|
3
|
+
export const PEER_REF_MAX = 12;
|
|
4
|
+
export const PEER_REF_RE = /^[0-9a-f]{6,12}$/;
|
|
5
|
+
function refHash(e) {
|
|
6
|
+
return createHash("sha256").update(`${e.kind}:${e.id}`).digest("hex");
|
|
7
|
+
}
|
|
8
|
+
function commonPrefixLen(a, b) {
|
|
9
|
+
let n = 0;
|
|
10
|
+
while (n < a.length && n < b.length && a[n] === b[n])
|
|
11
|
+
n++;
|
|
12
|
+
return n;
|
|
13
|
+
}
|
|
14
|
+
export function mintPeerRef(entries, self) {
|
|
15
|
+
const hashes = entries.map(refHash);
|
|
16
|
+
const universe = [...new Set([...hashes, ...(self !== undefined ? [refHash(self)] : [])])].sort();
|
|
17
|
+
const lengths = new Map();
|
|
18
|
+
for (let i = 0; i < universe.length; i++) {
|
|
19
|
+
const h = universe[i];
|
|
20
|
+
const shared = Math.max(i > 0 ? commonPrefixLen(h, universe[i - 1]) : 0, i + 1 < universe.length ? commonPrefixLen(h, universe[i + 1]) : 0);
|
|
21
|
+
lengths.set(h, Math.min(h.length, Math.max(PEER_REF_MIN, shared + 1)));
|
|
22
|
+
}
|
|
23
|
+
return entries.map((e, i) => {
|
|
24
|
+
const h = hashes[i];
|
|
25
|
+
return { ...e, ref: h.slice(0, Math.min(lengths.get(h), PEER_REF_MAX)) };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export function formatPeerNameRef(name, ref) {
|
|
29
|
+
return `${name} [${ref}]`;
|
|
30
|
+
}
|
|
31
|
+
export function parsePeerNameRef(text) {
|
|
32
|
+
const m = /^(.*\S)\s*\[([0-9a-f]{6,12})\]$/.exec(text.trim());
|
|
33
|
+
return m === null ? undefined : { name: m[1], ref: m[2] };
|
|
34
|
+
}
|
|
35
|
+
export function normalizePeerName(name) {
|
|
36
|
+
return name
|
|
37
|
+
.normalize("NFKC")
|
|
38
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, "")
|
|
39
|
+
.trim()
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/\s+/g, "-");
|
|
42
|
+
}
|
|
43
|
+
export const PEER_ADDRESS_PREFIXES = Object.freeze(["session.", "principal."]);
|
|
44
|
+
const SOCKET_PATH_RE = /^\/\S*\.sock/;
|
|
45
|
+
const NAMED_PIPE_RE = /^[\\/]{2}[.?][\\/]pipe[\\/]/i;
|
|
46
|
+
function isSocketPathSpelling(text) {
|
|
47
|
+
return SOCKET_PATH_RE.test(text) || NAMED_PIPE_RE.test(text);
|
|
48
|
+
}
|
|
49
|
+
export function reservedPeerNameReason(name) {
|
|
50
|
+
const key = normalizePeerName(name);
|
|
51
|
+
if (key === "")
|
|
52
|
+
return "empty";
|
|
53
|
+
if (key === "*")
|
|
54
|
+
return "wildcard";
|
|
55
|
+
if (key.includes("@"))
|
|
56
|
+
return "at_sign";
|
|
57
|
+
if (PEER_ADDRESS_PREFIXES.some((p) => key.startsWith(p)))
|
|
58
|
+
return "address_prefix";
|
|
59
|
+
if (/^(?:uds|bridge|did):/.test(key))
|
|
60
|
+
return "transport_scheme";
|
|
61
|
+
if (isSocketPathSpelling(name) || isSocketPathSpelling(key))
|
|
62
|
+
return "socket_path";
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
@@ -44,6 +44,19 @@ export interface SendMessageToolOptions {
|
|
|
44
44
|
* this run's process exits when the turn ends, so no completion notification can ever land — checked
|
|
45
45
|
* AHEAD of {@link notificationWired}, since no amount of wiring makes a later turn exist. */
|
|
46
46
|
oneShot?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Whether a RETRIEVAL tool (`TaskOutput`) is mounted beside this tool on the same face. Every
|
|
49
|
+
* degraded completion promise on this face (the one-shot and silent arms of the description and
|
|
50
|
+
* the receipt/refusal texts) tells the model how to collect a continued agent's result; naming
|
|
51
|
+
* `TaskOutput(task_id, block: true)` as an imperative is only true where that tool exists. Since
|
|
52
|
+
* design/385 §1.5 this tool also mounts on the delegated-child arm WITHOUT the background door
|
|
53
|
+
* TaskOutput/TaskStop keep, so the two can no longer be assumed to travel together. Three states:
|
|
54
|
+
* `true` — the advice names TaskOutput plainly; `false` — this face has no retrieval tool, and the
|
|
55
|
+
* advice says the result cannot be collected by this run instead of naming a tool the model cannot
|
|
56
|
+
* call (an unknown-tool error on a one-shot run is the turn where the result is lost); absent — the
|
|
57
|
+
* mount did not say, and the advice is hedged ("where that tool is mounted").
|
|
58
|
+
*/
|
|
59
|
+
retrievalToolMounted?: boolean;
|
|
47
60
|
/** Steer-handle sink: the revived run re-emits a FRESH handle (design/122 risk-table contract). */
|
|
48
61
|
sink?: (handle: SubagentSteerHandle) => void;
|
|
49
62
|
/**
|
|
@@ -6,6 +6,7 @@ import { uuidv7 } from "../internal/harness.js";
|
|
|
6
6
|
import { MAILBOX_TOMBSTONED_RECIPIENT_CODE } from "../core/mailbox-store.js";
|
|
7
7
|
import { escapeAttributeValue, escapeEnvelopeTag, isObserverTaskId, OBSERVER_SENDMESSAGE_SENDER_REFUSAL, OBSERVER_SENDMESSAGE_TARGET_REFUSAL, } from "./observer.js";
|
|
8
8
|
import { inlineUntrusted } from "../core/untrusted-text.js";
|
|
9
|
+
import { neutralizePeerBody } from "./cross-session-envelope.js";
|
|
9
10
|
import { SUBAGENT_RESUME_CAP, SubagentRetainLedger, getSessionRetainLedger } from "./retain-ledger.js";
|
|
10
11
|
import { createSubagentResume } from "./subagent.js";
|
|
11
12
|
import { appendHopToken, judgePeerAdmission, peerAxisToken, resolvePeerAdmissionConfig, PEER_MESSAGE_NOTICE, } from "./peer-admission.js";
|
|
@@ -24,7 +25,7 @@ export function clipSendMessageSummary(raw) {
|
|
|
24
25
|
const TEAMMATE_MESSAGE_TAG = "teammate-message";
|
|
25
26
|
function frameTeammateMessage(args) {
|
|
26
27
|
const summaryAttr = args.summary !== undefined ? ` summary="${escapeAttributeValue(args.summary)}"` : "";
|
|
27
|
-
const body = escapeEnvelopeTag(TEAMMATE_MESSAGE_TAG, args.text);
|
|
28
|
+
const body = escapeEnvelopeTag(TEAMMATE_MESSAGE_TAG, neutralizePeerBody(args.text));
|
|
28
29
|
return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${escapeAttributeValue(args.from)}"${summaryAttr}>\n${body}\n</${TEAMMATE_MESSAGE_TAG}>`;
|
|
29
30
|
}
|
|
30
31
|
const REVIVE_LEASE_TTL_MS = 5 * 60_000;
|
|
@@ -46,21 +47,36 @@ function targetLaneKey(scope, targetId) {
|
|
|
46
47
|
const OPERATOR_CONTINUATION_CTX = Symbol("sema.operator_continuation");
|
|
47
48
|
export function createSendMessageTool(opts) {
|
|
48
49
|
const tier3Capable = opts.agentStore !== undefined && opts.mailbox !== undefined && opts.reviveSpawn !== undefined;
|
|
50
|
+
const retrievalMounted = opts.retrievalToolMounted;
|
|
51
|
+
const retrievalHedge = retrievalMounted === undefined ? " (where that tool is mounted)" : "";
|
|
52
|
+
const RETRIEVE_NOW = retrievalMounted === false
|
|
53
|
+
? "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"
|
|
54
|
+
: `retrieve its result NOW with TaskOutput(task_id, block: true)${retrievalHedge} rather than ending your turn`;
|
|
55
|
+
const RETRIEVE_LATER = retrievalMounted === false
|
|
56
|
+
? "its result cannot be collected by this run (no TaskOutput is mounted beside this tool)"
|
|
57
|
+
: "retrieve its status and result with TaskOutput(task_id) where mounted";
|
|
58
|
+
const WAIT_NOW = retrievalMounted === false
|
|
59
|
+
? "do not wait for it — this face has no TaskOutput to block on, and"
|
|
60
|
+
: `wait for it NOW with TaskOutput(task_id, block: true)${retrievalHedge} —`;
|
|
61
|
+
const CHECK_LATER = retrievalMounted === false
|
|
62
|
+
? "its completion cannot be checked from this face (no TaskOutput is mounted beside this tool)"
|
|
63
|
+
: "check for its completion with TaskOutput(task_id) where mounted";
|
|
64
|
+
const capitalize = (text) => `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
|
|
49
65
|
const completionMode = opts.oneShot === true ? "one_shot" : (opts.notificationWired ?? opts.notify !== undefined) ? "notified" : "silent";
|
|
50
66
|
const notifyWired = completionMode === "notified";
|
|
51
67
|
const NO_COMPLETION_NOTICE = completionMode === "one_shot"
|
|
52
|
-
?
|
|
53
|
-
:
|
|
68
|
+
? `this is a ONE-SHOT submission — there is no later turn for a completion notification to land in, so ${RETRIEVE_NOW}`
|
|
69
|
+
: `its completion is NOT announced on this mount — ${RETRIEVE_LATER}`;
|
|
54
70
|
const AWAIT_COMPLETION = notifyWired
|
|
55
71
|
? "Wait for its completion notification"
|
|
56
72
|
: completionMode === "one_shot"
|
|
57
|
-
?
|
|
58
|
-
:
|
|
73
|
+
? `${capitalize(WAIT_NOW)} this one-shot submission ends with this turn`
|
|
74
|
+
: capitalize(CHECK_LATER);
|
|
59
75
|
const awaitCompletion = notifyWired
|
|
60
76
|
? "wait for its completion notification"
|
|
61
77
|
: completionMode === "one_shot"
|
|
62
|
-
?
|
|
63
|
-
:
|
|
78
|
+
? `${WAIT_NOW} this one-shot submission ends with this turn`
|
|
79
|
+
: CHECK_LATER;
|
|
64
80
|
const sendMessagePins = new Map();
|
|
65
81
|
const pinGuard = (targetId, targetName, rung, to) => {
|
|
66
82
|
if (rung === "other")
|
|
@@ -104,8 +120,12 @@ export function createSendMessageTool(opts) {
|
|
|
104
120
|
(notifyWired
|
|
105
121
|
? `You will be notified automatically when it completes — prefer ending your turn; do not poll. `
|
|
106
122
|
: 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
|
-
|
|
123
|
+
? `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
|
|
124
|
+
? "; a continued agent's result cannot be collected by this run (no TaskOutput is mounted beside this tool) — say so if the result matters"
|
|
125
|
+
: ` — wait actively with TaskOutput(task_id, block: true)${retrievalHedge} when you need a continued agent's result`}. `
|
|
126
|
+
: `A finished agent is NOT announced on this mount${retrievalMounted === false
|
|
127
|
+
? ", 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"
|
|
128
|
+
: " — check on it with TaskOutput(task_id) where mounted rather than ending your turn to wait for a notification that never comes"}. `) +
|
|
109
129
|
(tier3Capable
|
|
110
130
|
? `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
131
|
: `Continuing a finished agent requires the run to retain sub-agent sessions; when the session was not ` +
|
|
@@ -191,15 +211,19 @@ export function createSendMessageTool(opts) {
|
|
|
191
211
|
if (!verdict.ok)
|
|
192
212
|
return admissionRefusal(verdict.reason, "main");
|
|
193
213
|
}
|
|
214
|
+
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;
|
|
215
|
+
const uplinkSeq = ++uplinkSeqGlobal;
|
|
194
216
|
try {
|
|
195
217
|
opts.uplink({
|
|
196
218
|
task_id: senderId,
|
|
197
219
|
task_type: "background_agent",
|
|
198
220
|
status: "event",
|
|
199
221
|
summary: `message from ${senderLabel}: ${summary}`,
|
|
200
|
-
result: `${
|
|
201
|
-
seq:
|
|
222
|
+
result: `${uplinkBody}\n\n${PEER_MESSAGE_NOTICE}`,
|
|
223
|
+
seq: uplinkSeq,
|
|
202
224
|
peer: { hopChain: prospectiveChain },
|
|
225
|
+
agentMessage: { from: senderLabel, body: uplinkBody },
|
|
226
|
+
_sema_provenance: { kind: "agent_message", from: senderLabel, taskId: senderId, seq: uplinkSeq },
|
|
203
227
|
}, { priority: "next" });
|
|
204
228
|
}
|
|
205
229
|
catch (e) {
|
|
@@ -207,7 +231,7 @@ export function createSendMessageTool(opts) {
|
|
|
207
231
|
}
|
|
208
232
|
return {
|
|
209
233
|
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:
|
|
234
|
+
details: { type: "send-message", status: "uplinked", to: "main", seq: uplinkSeq },
|
|
211
235
|
};
|
|
212
236
|
}
|
|
213
237
|
if (!senderIsChild) {
|