@sema-agent/core 7.0.2 → 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 +58 -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/repair-loop.d.ts +8 -7
- package/dist/agents/roster-store.d.ts +7 -2
- package/dist/agents/send-message-tool.d.ts +13 -0
- package/dist/agents/send-message-tool.js +36 -12
- package/dist/brain/errors.d.ts +18 -0
- package/dist/brain/errors.js +3 -0
- package/dist/brain/stream-engine.js +6 -4
- package/dist/core/checkpoint-store.d.ts +189 -3
- package/dist/core/checkpoint-store.js +56 -16
- package/dist/core/context-edit.d.ts +3 -0
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +4 -0
- package/dist/core/hooks.d.ts +34 -7
- package/dist/core/hooks.js +14 -8
- package/dist/core/image-downsample.d.ts +4 -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/roles.d.ts +30 -8
- package/dist/core/roles.js +12 -8
- package/dist/core/runner/assemble-result.js +2 -1
- package/dist/core/runner/prepare-task.d.ts +41 -2
- package/dist/core/runner/prepare-task.js +353 -152
- 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.d.ts +12 -3
- package/dist/core/runner/runtask.js +45 -7
- package/dist/core/safety-axis-vocab.d.ts +1 -1
- package/dist/core/strategy-store.d.ts +4 -1
- package/dist/core/task-notification.d.ts +64 -5
- package/dist/core/task-notification.js +25 -4
- package/dist/core/task-registry-shared.d.ts +7 -3
- package/dist/core/tool-errors.d.ts +1 -1
- package/dist/core/tool-policy.d.ts +51 -7
- package/dist/core/tool-policy.js +63 -9
- package/dist/core/types.d.ts +110 -9
- package/dist/core/untrusted-text.js +17 -1
- package/dist/engine/compaction/compaction.js +6 -2
- package/dist/engine/harness/agent-harness.d.ts +28 -6
- package/dist/engine/harness/agent-harness.js +34 -2
- package/dist/engine/harness/messages.js +4 -0
- package/dist/engine/harness/types.d.ts +37 -0
- package/dist/engine/harness/types.js +5 -0
- package/dist/engine/session/session.js +3 -2
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -2
- package/dist/internal/harness.d.ts +1 -0
- package/dist/internal/harness.js +1 -0
- package/dist/orchestration/builtin-workflows.d.ts +17 -9
- package/dist/orchestration/run-workflow-tool.js +7 -2
- package/dist/orchestration/workflow-governance.js +1 -1
- package/dist/orchestration/workflow-types.d.ts +1 -0
- package/dist/orchestration/workflow.js +1 -1
- package/dist/stores/file/mailbox-store.d.ts +2 -1
- 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
|
+
}
|
|
@@ -13,7 +13,7 @@ import { type OracleIsolationVerdict } from "../core/oracle-isolation.js";
|
|
|
13
13
|
* `oracleIsolation` boundary that this slice deliberately does not build — SAFE-tier escalates to a human
|
|
14
14
|
* (`needs_human_oracle`) or surfaces a candidate, but never clears its own work.
|
|
15
15
|
*
|
|
16
|
-
* Like {@link verifyCompleted}, this is a **thin composition** over `runner.runTask` (verify.ts
|
|
16
|
+
* Like {@link verifyCompleted}, this is a **thin composition** over `runner.runTask` (verify.ts's DEPLOYMENT POSTURE section)
|
|
17
17
|
* — it adds no Runner-core surface, touches no vendored code, and is called by a leader/profile (the sibling
|
|
18
18
|
* of {@link runWithVerification}). The oracle internals (which gate/judge/property-harness to compose) live
|
|
19
19
|
* in the PROFILE-injected {@link RepairOracle} closure; core only fixes the {@link OracleResult} shape and the
|
|
@@ -75,12 +75,13 @@ export interface OracleResult {
|
|
|
75
75
|
}
|
|
76
76
|
/**
|
|
77
77
|
* The PROFILE-injected oracle (§1 裁决①). The profile composes the actual grading inside this closure —
|
|
78
|
-
* `runExecGate(graderEnv, steps)` (exec-gate.ts
|
|
79
|
-
* grader) → `verifyCompleted` (verify.ts
|
|
80
|
-
* (property-harness.ts
|
|
78
|
+
* `runExecGate(graderEnv, steps)` (core/exec-gate.ts, env is the FIRST param = L2 provenance bound to the
|
|
79
|
+
* grader) → `verifyCompleted` (agents/verify.ts, the read-only L3 judge) → `checkInvariants`
|
|
80
|
+
* (core/property-harness.ts, explore-only) — and maps the composite to an {@link OracleResult}. Core never
|
|
81
81
|
* composes these internals (so the oracle type can't grow a long discriminated union); it only fixes the
|
|
82
82
|
* `OracleResult` shape and the read-only/identity contract. `graderEnv` is the isolated grader env; `evidence`
|
|
83
|
-
* is the diff/results to judge (recompute it from the post-resume working tree on a resume —
|
|
83
|
+
* is the diff/results to judge (recompute it from the post-resume working tree on a resume — threat BUG5, the
|
|
84
|
+
* same freshness duty verify.ts states on its own `evidence` config field).
|
|
84
85
|
*/
|
|
85
86
|
export type RepairOracle = (graderEnv: ExecutionEnv, evidence: string | undefined) => Promise<OracleResult>;
|
|
86
87
|
/**
|
|
@@ -166,12 +167,12 @@ export interface RepairLoopConfig {
|
|
|
166
167
|
*/
|
|
167
168
|
resumeBundle?: RepairBundle;
|
|
168
169
|
/**
|
|
169
|
-
* Stop the loop once cumulative cost reaches this (verify.ts
|
|
170
|
+
* Stop the loop once cumulative cost reaches this (verify.ts's `costCeilingMicroUsd` backstop). HRD-AGO-6 — "cumulative" is
|
|
170
171
|
* every leg the loop pays for: each attempt's own + nested `runTask` cost PLUS each verdict's
|
|
171
172
|
* {@link OracleResult.costMicroUsd} (an LLM-judge oracle is charged per attempt too).
|
|
172
173
|
*/
|
|
173
174
|
costCeilingMicroUsd?: number;
|
|
174
|
-
/** Overall ACTIVE wall-clock ceiling for the whole loop (verify.ts
|
|
175
|
+
/** Overall ACTIVE wall-clock ceiling for the whole loop (verify.ts's `totalTimeoutMs` backstop). F1: both ceilings
|
|
175
176
|
* are CROSS-RESUME — the carried {@link RepairBundle.spentMicroUsd}/{@link RepairBundle.activeElapsedMs}
|
|
176
177
|
* re-seed the accounts, so a durable resume continues the budget rather than restarting it. */
|
|
177
178
|
totalTimeoutMs?: number;
|
|
@@ -12,8 +12,13 @@ export interface RosterEntry {
|
|
|
12
12
|
* key; equals the spawner's session at depth 1). Stored verbatim; no predicate arm consumes it
|
|
13
13
|
* yet (enumeration/recovery is the reader). */
|
|
14
14
|
rootSessionId?: string;
|
|
15
|
-
/** design/151 S3b — the
|
|
16
|
-
*
|
|
15
|
+
/** design/151 S3b — the model RECORD KEY at spawn (revival lookup key; display/model-routing hints
|
|
16
|
+
* only, never a serialized spec). NOT the resolved model id: a spawn that named a model in WORDS
|
|
17
|
+
* records the caller's spelling VERBATIM (a catalog key, tier word or CC alias), because a revival
|
|
18
|
+
* re-resolves this value against the catalog in force at WAKE time and a resolved id would only
|
|
19
|
+
* ever resolve again on deployments whose catalog keys happen to equal model ids. A spawn that
|
|
20
|
+
* carried a Model OBJECT (definition/tool-level/inherited ref) records that object's id, which on
|
|
21
|
+
* such a deployment is not a catalog key and degrades softly on the revival lane. */
|
|
17
22
|
model?: string;
|
|
18
23
|
/** Ruled 2026-08-05: the spawn requested a model word that did not bind — the row runs on its
|
|
19
24
|
* inherited default. Closed set, single member today; absent = bound normally (or no word). */
|
|
@@ -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) {
|
package/dist/brain/errors.d.ts
CHANGED
|
@@ -82,6 +82,24 @@ export declare function readProviderRequestId(res: {
|
|
|
82
82
|
get?: (name: string) => string | null;
|
|
83
83
|
};
|
|
84
84
|
} | undefined): string | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Does this HTTP status NAME the failure it is being attached to? The one predicate governing every
|
|
87
|
+
* seat on which this engine publishes a provider status — the terminal assistant frame's
|
|
88
|
+
* `apiErrorStatus` and the retry-wait status frame's `errorStatus` (#506 ㋑). One helper on purpose:
|
|
89
|
+
* both seats promise the same three-state discipline, and two copies of the rule would drift.
|
|
90
|
+
*
|
|
91
|
+
* Two distinct shapes are refused, and both are reachable:
|
|
92
|
+
* · The engine's "no status" SENTINEL, 0 — the terminal HTTP throw carries `r?.status ?? 0` when a
|
|
93
|
+
* transport hands back neither a response nor a throw. Publishing it would be absence rendered as a
|
|
94
|
+
* number a consumer may format, which is worse than silence.
|
|
95
|
+
* · A SUCCESS status. The connect loop only streams a response that is `ok` AND has a body, so an
|
|
96
|
+
* `ok` response with a NULL body (an empty 200 from a proxy, a 204) reaches the terminal throw
|
|
97
|
+
* carrying 200 — and a provider's own `x-should-retry` verdict can send that same 2xx into a RETRY
|
|
98
|
+
* wait. The boundary really did fail, but the status is not what failed, and a consumer routing or
|
|
99
|
+
* rendering by a 2xx is exactly the misreading these seats exist to prevent.
|
|
100
|
+
* Stalls, connect failures and in-band error frames inside a 200 leave the seat absent by the same rule.
|
|
101
|
+
*/
|
|
102
|
+
export declare function namesTheFailure(status: number | undefined): status is number;
|
|
85
103
|
/** Map an HTTP status to an error class. 401/403 = auth (don't retry); 429 = rate limit; 5xx = server. */
|
|
86
104
|
export declare function classifyHttp(status: number): BrainErrorCode;
|
|
87
105
|
/** Lift the machine-readable code back out of a `[code] …` prefixed `errorMessage` (the single shared
|
package/dist/brain/errors.js
CHANGED
|
@@ -45,6 +45,9 @@ export function readProviderRequestId(res) {
|
|
|
45
45
|
}
|
|
46
46
|
return undefined;
|
|
47
47
|
}
|
|
48
|
+
export function namesTheFailure(status) {
|
|
49
|
+
return status !== undefined && status >= 100 && !(status >= 200 && status < 300);
|
|
50
|
+
}
|
|
48
51
|
export function classifyHttp(status) {
|
|
49
52
|
if (status === 401 || status === 403)
|
|
50
53
|
return "auth";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createAssistantMessageEventStream, } from "../internal/llm.js";
|
|
2
2
|
import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } from "./context-overflow.js";
|
|
3
|
-
import { BrainError, classifyConnectFailure, classifyHttp, describeNetworkError, readProviderRequestId } from "./errors.js";
|
|
3
|
+
import { BrainError, classifyConnectFailure, classifyHttp, describeNetworkError, namesTheFailure, readProviderRequestId } from "./errors.js";
|
|
4
4
|
import { classifyInputTooLong } from "./input-too-long.js";
|
|
5
5
|
import { FAST_MAX_BACKOFF_MS, providerWaitHint, retryBackoffMs } from "./retry.js";
|
|
6
6
|
import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
|
|
@@ -267,9 +267,8 @@ export function runStreamingBrain(args) {
|
|
|
267
267
|
errorMsg.inputTooLongRuledOut = true;
|
|
268
268
|
if (err.apiError === true) {
|
|
269
269
|
errorMsg.isApiErrorMessage = true;
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
errorMsg.apiErrorStatus = st;
|
|
270
|
+
if (namesTheFailure(err.status))
|
|
271
|
+
errorMsg.apiErrorStatus = err.status;
|
|
273
272
|
if (requestIdSeen !== undefined)
|
|
274
273
|
errorMsg.requestId = requestIdSeen;
|
|
275
274
|
}
|
|
@@ -376,6 +375,7 @@ export function runStreamingBrain(args) {
|
|
|
376
375
|
attempt: attempt + 1,
|
|
377
376
|
maxRetries,
|
|
378
377
|
errClass: "output_cap",
|
|
378
|
+
...(namesTheFailure(r?.status) ? { errorStatus: r.status } : {}),
|
|
379
379
|
});
|
|
380
380
|
continue;
|
|
381
381
|
}
|
|
@@ -428,6 +428,7 @@ export function runStreamingBrain(args) {
|
|
|
428
428
|
discardResponseBody(r);
|
|
429
429
|
cc.abort();
|
|
430
430
|
cc.dispose();
|
|
431
|
+
const retryStatus = namesTheFailure(r?.status) ? r.status : undefined;
|
|
431
432
|
await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
|
|
432
433
|
phase: statusPhase,
|
|
433
434
|
detail: statusPhase === "rate_limited"
|
|
@@ -440,6 +441,7 @@ export function runStreamingBrain(args) {
|
|
|
440
441
|
attempt: attempt + 1,
|
|
441
442
|
maxRetries: laneMaxRetries,
|
|
442
443
|
errClass,
|
|
444
|
+
...(retryStatus !== undefined ? { errorStatus: retryStatus } : {}),
|
|
443
445
|
}));
|
|
444
446
|
continue;
|
|
445
447
|
}
|