@sema-agent/core 7.2.0 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/dist/agents/cross-session-envelope.d.ts +7 -0
- package/dist/agents/cross-session-envelope.js +4 -0
- package/dist/agents/list-agents-tool.d.ts +55 -0
- package/dist/agents/list-agents-tool.js +94 -0
- package/dist/agents/peer-admission.d.ts +17 -1
- package/dist/agents/peer-admission.js +19 -2
- package/dist/agents/peer-directory.d.ts +208 -0
- package/dist/agents/peer-directory.js +272 -0
- package/dist/agents/peer-session-drain.d.ts +159 -0
- package/dist/agents/peer-session-drain.js +245 -0
- package/dist/agents/send-message-tool.d.ts +31 -0
- package/dist/agents/send-message-tool.js +145 -4
- package/dist/agents/subagent-steps.d.ts +11 -0
- package/dist/agents/subagent-steps.js +27 -4
- package/dist/core/auto-mode-arming.d.ts +11 -0
- package/dist/core/auto-mode-arming.js +7 -1
- package/dist/core/auto-mode-prompt.d.ts +5 -0
- package/dist/core/auto-mode-prompt.js +2 -1
- package/dist/core/auto-mode-rebuild.d.ts +2 -1
- package/dist/core/auto-mode-rebuild.js +2 -0
- package/dist/core/checkpoint-store.d.ts +14 -0
- package/dist/core/checkpoint-store.js +4 -3
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +6 -0
- package/dist/core/mailbox-store.d.ts +89 -2
- package/dist/core/mailbox-store.js +77 -2
- package/dist/core/permission-rule-model.d.ts +9 -0
- package/dist/core/permission-rule-model.js +4 -1
- package/dist/core/runner/prepare-task.d.ts +20 -0
- package/dist/core/runner/prepare-task.js +137 -37
- package/dist/core/runner/runtask.js +3 -2
- package/dist/core/runner/tool-output-projection.js +1 -0
- package/dist/core/store-contracts/mailbox-store-contract.d.ts +23 -0
- package/dist/core/store-contracts/mailbox-store-contract.js +157 -1
- package/dist/core/task-notification.d.ts +38 -9
- package/dist/core/task-notification.js +8 -2
- package/dist/core/types.d.ts +132 -21
- package/dist/core/wiring-manifest.d.ts +21 -0
- package/dist/core/wiring-manifest.js +1 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.js +9 -3
- package/dist/stores/cc/mailbox-store.d.ts +1 -1
- package/dist/stores/cc/mailbox-store.js +13 -0
- package/dist/stores/file/adoption/marker.d.ts +1 -1
- package/dist/stores/file/mailbox-store.d.ts +57 -0
- package/dist/stores/file/mailbox-store.js +369 -18
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +109 -1
|
@@ -3,6 +3,8 @@ import type { Runner } from "../core/runner/runtask.js";
|
|
|
3
3
|
import type { TaskNotificationPayload } from "../core/task-notification.js";
|
|
4
4
|
import { type ToolCtxEnricher } from "../core/tools.js";
|
|
5
5
|
import { type TaskAccess } from "../core/task-registry.js";
|
|
6
|
+
import type { PermissionModeClass } from "./cross-session-envelope.js";
|
|
7
|
+
import { type PeerDirectory } from "./peer-directory.js";
|
|
6
8
|
import { SubagentRetainLedger } from "./retain-ledger.js";
|
|
7
9
|
import { type SubagentSteerHandle } from "./subagent.js";
|
|
8
10
|
import { type PeerAdmissionConfig, type PeerInboundChainRef, type PeerSelfRef } from "./peer-admission.js";
|
|
@@ -164,6 +166,35 @@ export interface SendMessageToolOptions {
|
|
|
164
166
|
* per-call honest refusal is the only loudness (unchanged behavior).
|
|
165
167
|
*/
|
|
166
168
|
onTranscriptIntegrityGap?: (handle: string, scope: string | undefined) => void;
|
|
169
|
+
/**
|
|
170
|
+
* design/385 §2 — the peer-session DIRECTORY ({@link import("../core/types.js").RunnerDeps.peerDirectory}):
|
|
171
|
+
* opens the cross-session lane's two rungs on this face — the explicit `session.<id>` address arm
|
|
172
|
+
* (ranked beside the precise a* id) and the LAST name rung (`name [ref]` over the directory's full
|
|
173
|
+
* set, after every agent rung missed). Needs {@link mailbox} beside it AND that store's
|
|
174
|
+
* `crossProcessSafe: true` declaration: with the directory wired but either missing, the lane stays
|
|
175
|
+
* closed and every peer-addressed send refuses with the named reason (never a silent fall-through to
|
|
176
|
+
* "no agent matches"). Absent ⇒ byte-identical pre-385 behavior.
|
|
177
|
+
*/
|
|
178
|
+
peerDirectory?: PeerDirectory;
|
|
179
|
+
/**
|
|
180
|
+
* design/385 — THIS session's own identity on the cross-session lane: its session id (the sender's
|
|
181
|
+
* `from-session` / reply address, and the self-target guard), the scope its directory rows live in,
|
|
182
|
+
* its display name (the `from-name` attestation), and the live fold of its own permission-mode class
|
|
183
|
+
* ({@link import("./cross-session-judge.js").foldPermissionModeClass} over the wiring manifest's
|
|
184
|
+
* `ask.effective`) — read at SEND time, attested on the parked record's typed `peerMeta.fromMode`.
|
|
185
|
+
* The Runner mount fills it; a direct mount without it sends with no session attestation (the
|
|
186
|
+
* recipient then judges it as "no class asserted").
|
|
187
|
+
*/
|
|
188
|
+
peerSelfSession?: {
|
|
189
|
+
sessionId: string;
|
|
190
|
+
scope?: string;
|
|
191
|
+
name?: string;
|
|
192
|
+
modeClass?: () => PermissionModeClass | "unknown";
|
|
193
|
+
};
|
|
194
|
+
/** design/385 §5.1 — is `ListAgents` mounted beside this tool? Drives the "list them with ListAgents"
|
|
195
|
+
* phrase (a face never names a tool the roster does not carry — an `excludeTools` may drop one of the
|
|
196
|
+
* pair). Absent = unknown ⇒ the phrase is hedged. */
|
|
197
|
+
peerListingMounted?: boolean;
|
|
167
198
|
}
|
|
168
199
|
/** RB-382 — max chars of the `summary` arg (the short recap label, distinct from `message`'s much
|
|
169
200
|
* larger {@link UPLINK_RESULT_MAX}) before ITS OWN truncation. Exported for direct unit-testing only
|
|
@@ -3,7 +3,9 @@ import { defineTool } from "../core/tools.js";
|
|
|
3
3
|
import { normalizeAgentName, DURABLE_AGENT_HANDLE_RE, DURABLE_AGENT_HEARTBEAT_MS } from "../core/task-registry.js";
|
|
4
4
|
import { announceTranscriptIntegrityGapOnce, canAccessAgentRecord, clearRevivedRowTerminalPayload } from "../core/background-agent-store.js";
|
|
5
5
|
import { uuidv7 } from "../internal/harness.js";
|
|
6
|
-
import { MAILBOX_TOMBSTONED_RECIPIENT_CODE } from "../core/mailbox-store.js";
|
|
6
|
+
import { MAILBOX_TOMBSTONED_RECIPIENT_CODE, mailboxCrossProcessMountVerdict } from "../core/mailbox-store.js";
|
|
7
|
+
import { peerSendVerdictSeverity } from "./cross-session-judge.js";
|
|
8
|
+
import { parsePeerSessionAddress, peerSessionBoxHandle, resolvePeerSessions } from "./peer-directory.js";
|
|
7
9
|
import { escapeAttributeValue, escapeEnvelopeTag, isObserverTaskId, OBSERVER_SENDMESSAGE_SENDER_REFUSAL, OBSERVER_SENDMESSAGE_TARGET_REFUSAL, } from "./observer.js";
|
|
8
10
|
import { inlineUntrusted } from "../core/untrusted-text.js";
|
|
9
11
|
import { neutralizePeerBody } from "./cross-session-envelope.js";
|
|
@@ -29,6 +31,7 @@ function frameTeammateMessage(args) {
|
|
|
29
31
|
return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${escapeAttributeValue(args.from)}"${summaryAttr}>\n${body}\n</${TEAMMATE_MESSAGE_TAG}>`;
|
|
30
32
|
}
|
|
31
33
|
const REVIVE_LEASE_TTL_MS = 5 * 60_000;
|
|
34
|
+
const PEER_REFUSAL_TEXT_MAX = 1200;
|
|
32
35
|
const sendMessageTargetLanes = new Map();
|
|
33
36
|
function withTargetLane(key, fn) {
|
|
34
37
|
const prev = sendMessageTargetLanes.get(key) ?? Promise.resolve();
|
|
@@ -47,6 +50,14 @@ function targetLaneKey(scope, targetId) {
|
|
|
47
50
|
const OPERATOR_CONTINUATION_CTX = Symbol("sema.operator_continuation");
|
|
48
51
|
export function createSendMessageTool(opts) {
|
|
49
52
|
const tier3Capable = opts.agentStore !== undefined && opts.mailbox !== undefined && opts.reviveSpawn !== undefined;
|
|
53
|
+
const peerLane = (() => {
|
|
54
|
+
if (opts.peerDirectory === undefined)
|
|
55
|
+
return { active: false };
|
|
56
|
+
if (opts.mailbox === undefined)
|
|
57
|
+
return { active: false, reason: "a peer-session directory is wired but no mailbox store is (the lane parks messages in the recipient's durable session box)" };
|
|
58
|
+
const verdict = mailboxCrossProcessMountVerdict(opts.mailbox);
|
|
59
|
+
return verdict.ok ? { active: true } : { active: false, reason: verdict.reason };
|
|
60
|
+
})();
|
|
50
61
|
const retrievalMounted = opts.retrievalToolMounted;
|
|
51
62
|
const retrievalHedge = retrievalMounted === undefined ? " (where that tool is mounted)" : "";
|
|
52
63
|
const RETRIEVE_NOW = retrievalMounted === false
|
|
@@ -129,7 +140,10 @@ export function createSendMessageTool(opts) {
|
|
|
129
140
|
(tier3Capable
|
|
130
141
|
? `Continuing a finished agent works for agents with a durable record — by name or task_id, even across restarts — and for runs that retain sub-agent sessions; when neither covers it you get an honest error and should launch a new agent with the needed context instead.`
|
|
131
142
|
: `Continuing a finished agent requires the run to retain sub-agent sessions; when the session was not ` +
|
|
132
|
-
`retained (or is no longer held) you get an honest error and should launch a new agent with the needed context instead.`)
|
|
143
|
+
`retained (or is no longer held) you get an honest error and should launch a new agent with the needed context instead.`) +
|
|
144
|
+
(peerLane.active
|
|
145
|
+
? ` Peer SESSIONS — other sessions of this engine for the same user${opts.peerListingMounted === false ? "" : opts.peerListingMounted === true ? " (list them with ListAgents)" : " (list them with ListAgents where that tool is mounted)"} — are addressed by name, by \`name [ref]\` when a name is ambiguous, or by their \`session.<id>\` address. A message to a peer is parked durably in its session box and read at its next turn boundary (an offline peer reads it when it next runs); the receipt confirms the box accepted it, not that the peer acted on it.`
|
|
146
|
+
: ""),
|
|
133
147
|
parameters: Type.Object({
|
|
134
148
|
to: Type.String({ description: 'Recipient: the agent\'s name, or its task_id (a…) returned by the Agent tool with run_in_background. "main" is reserved for the spawning conversation.' }),
|
|
135
149
|
message: Type.String({ description: "The follow-up request. The agent continues from its full prior context." }),
|
|
@@ -179,9 +193,9 @@ export function createSendMessageTool(opts) {
|
|
|
179
193
|
return { content: OBSERVER_SENDMESSAGE_TARGET_REFUSAL, details: { error: "observer_target", to }, isError: true };
|
|
180
194
|
}
|
|
181
195
|
const admissionConfig = resolvePeerAdmissionConfig(opts.admission);
|
|
182
|
-
const guardScope = ctx.principal ?? opts.scope;
|
|
196
|
+
const guardScope = ctx.principal ?? opts.scope ?? opts.peerSelfSession?.scope;
|
|
183
197
|
const selfRef = ctx.peerSelfRef ?? opts.peerSelf;
|
|
184
|
-
const directMountSessionId = ctx.sessionId ?? opts.sessionId;
|
|
198
|
+
const directMountSessionId = ctx.sessionId ?? opts.sessionId ?? opts.peerSelfSession?.sessionId;
|
|
185
199
|
const directMountTaskId = ctx.taskId ?? opts.owner;
|
|
186
200
|
const senderKey = selfRef?.current.key ??
|
|
187
201
|
(directMountSessionId !== undefined
|
|
@@ -203,6 +217,125 @@ export function createSendMessageTool(opts) {
|
|
|
203
217
|
: `this message's forwarding chain is too long (runaway relay) — stop relaying it; act on it or drop it.`;
|
|
204
218
|
return { content: `Message not sent: ${text}`, details: { error: reason, code: reason, to }, isError: true };
|
|
205
219
|
};
|
|
220
|
+
const peerRefusal = (code, text, extra) => {
|
|
221
|
+
const verdict = { ok: false, code, severity: peerSendVerdictSeverity(code), message: text };
|
|
222
|
+
return { content: `Message not sent: ${text.slice(0, PEER_REFUSAL_TEXT_MAX)}`, details: { error: code, code, to, verdict, ...extra }, isError: true };
|
|
223
|
+
};
|
|
224
|
+
const peerSelfSessionId = opts.peerSelfSession?.sessionId ?? directMountSessionId;
|
|
225
|
+
const peerScope = guardScope ?? opts.peerSelfSession?.scope;
|
|
226
|
+
let ownRowName;
|
|
227
|
+
const resolvePeerTarget = async () => {
|
|
228
|
+
try {
|
|
229
|
+
const rows = await opts.peerDirectory.listPeerSessions({ scope: peerScope ?? "" });
|
|
230
|
+
if (peerSelfSessionId !== undefined)
|
|
231
|
+
ownRowName = rows.find((r) => r.sessionId.toLowerCase() === peerSelfSessionId.toLowerCase())?.name;
|
|
232
|
+
return resolvePeerSessions(rows, to, peerSelfSessionId !== undefined ? { sessionId: peerSelfSessionId } : undefined);
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
return { status: "directory_fault", message: e instanceof Error ? e.message : String(e) };
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const deliverToPeerSession = async (c) => {
|
|
239
|
+
const record = c.record;
|
|
240
|
+
const handle = peerSessionBoxHandle(record.sessionId);
|
|
241
|
+
const peerLabel = `peer session "${inlineUntrusted(record.name, 64)}" (${handle})`;
|
|
242
|
+
if (peerScope === undefined)
|
|
243
|
+
return peerRefusal("peer_send.other", `this mount has no scope axis, so ${peerLabel}'s box cannot be addressed.`);
|
|
244
|
+
if (record.inboundPosture === "unavailable") {
|
|
245
|
+
return peerRefusal("peer_send.recipient_refuses", `${peerLabel} advertises that it does not accept cross-session messages (its crossSessionInbound is "refuse"). Nothing was parked.`);
|
|
246
|
+
}
|
|
247
|
+
const recipientKey = peerAxisToken(peerScope, "s", record.sessionId);
|
|
248
|
+
const storedContent = `[${summary}] ${clipCarrierMessage(message)}`;
|
|
249
|
+
const sendVerdict = judgePeerAdmission(peerScope, recipientKey, { senderKey, body: storedContent, prospectiveChain, ownTokens: [recipientKey] }, admissionConfig, undefined, "send");
|
|
250
|
+
if (!sendVerdict.ok)
|
|
251
|
+
return admissionRefusal(sendVerdict.reason, peerLabel);
|
|
252
|
+
let boxFull = false;
|
|
253
|
+
try {
|
|
254
|
+
boxFull = (await opts.mailbox.peekCount(peerScope, handle)) >= admissionConfig.maxQueuedPeerMessages;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
}
|
|
258
|
+
if (boxFull) {
|
|
259
|
+
return { content: `Message not sent: ${inlineUntrusted(peerLabel, 200)}'s session box is at its queued-message limit (${admissionConfig.maxQueuedPeerMessages.toFixed(0)}) — the message was NOT queued; it drains the box at its next turn. Resend later. ${DEDUP_RETRY_NOTE.slice(0, 200)}`, details: { error: "queue_full", code: "queue_full", to }, isError: true };
|
|
260
|
+
}
|
|
261
|
+
const modeClass = opts.peerSelfSession?.modeClass?.();
|
|
262
|
+
const peerMeta = {
|
|
263
|
+
...(peerSelfSessionId !== undefined ? { fromSession: peerSelfSessionId } : {}),
|
|
264
|
+
...(modeClass === "bypass" || modeClass === "prompting" ? { fromMode: modeClass } : {}),
|
|
265
|
+
...(senderKey !== undefined ? { senderKey } : {}),
|
|
266
|
+
kind: "peer_message",
|
|
267
|
+
};
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
let seq;
|
|
270
|
+
try {
|
|
271
|
+
seq = await opts.mailbox.append(peerScope, handle, { from: ownRowName ?? opts.peerSelfSession?.name ?? senderLabel, content: storedContent, sentAt: now, hopChain: prospectiveChain, peerMeta });
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
if (e?.code === MAILBOX_TOMBSTONED_RECIPIENT_CODE) {
|
|
275
|
+
return peerRefusal("peer_send.invalid_target", `${peerLabel} is being deleted — its box no longer accepts messages. Nothing was parked; do not retry.`);
|
|
276
|
+
}
|
|
277
|
+
return peerRefusal("peer_send.other", `the session box refused the message (${e instanceof Error ? e.message : String(e)}) — nothing was parked. ${DEDUP_RETRY_NOTE}`);
|
|
278
|
+
}
|
|
279
|
+
let rowRestored = "present";
|
|
280
|
+
try {
|
|
281
|
+
const rowsNow = await opts.peerDirectory.listPeerSessions({ scope: peerScope });
|
|
282
|
+
const nowRow = rowsNow.find((r) => r.sessionId.toLowerCase() === record.sessionId.toLowerCase());
|
|
283
|
+
if (nowRow?.liveness === "deleted")
|
|
284
|
+
rowRestored = "deleted_meanwhile";
|
|
285
|
+
else if (nowRow === undefined) {
|
|
286
|
+
rowRestored = opts.peerDirectory.restorePeerSession !== undefined && (await opts.peerDirectory.restorePeerSession(record)) ? "restored" : "unrestorable";
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
}
|
|
291
|
+
const rowNote = rowRestored === "unrestorable"
|
|
292
|
+
? " Note: the peer's directory row disappeared while this message was being parked and this directory cannot restore it — the message is safe in the box (it drains by session id), but the name may not resolve until that session next registers; its session.<id> address still does."
|
|
293
|
+
: rowRestored === "deleted_meanwhile"
|
|
294
|
+
? " Note: that session was DELETED while this message was being parked — its box is being retired and this message will not be read; do not resend to it."
|
|
295
|
+
: "";
|
|
296
|
+
const offline = record.liveness !== "live";
|
|
297
|
+
const verdict = { ok: true, disposition: offline ? "parked_offline" : "queued", via: "store" };
|
|
298
|
+
return {
|
|
299
|
+
content: offline
|
|
300
|
+
? `Message parked for ${peerLabel}: that session is offline, so the message was parked durably in its session box (seq ${seq}) and will be delivered when it next runs — subject to the deployment's mailbox retirement policy, which does not notify you. Continue with your task; do not resend the same content.${rowNote}`
|
|
301
|
+
: `Message queued for ${peerLabel} (seq ${seq}): it is parked durably in that session's box and will be read at its next turn boundary. This receipt confirms acceptance into the box, not the peer's judgment of the message. Continue with your task; do not resend the same content.${rowNote}`,
|
|
302
|
+
details: { type: "send-message", status: offline ? "peer_parked_offline" : "peer_queued", to, address: handle, seq, verdict, ...(rowRestored !== "present" ? { directoryRow: rowRestored } : {}) },
|
|
303
|
+
};
|
|
304
|
+
};
|
|
305
|
+
const settlePeerResolution = async (r) => {
|
|
306
|
+
switch (r.status) {
|
|
307
|
+
case "directory_fault":
|
|
308
|
+
return peerRefusal("peer_send.other", `the peer-session directory could not be read (${r.message}) — nothing was sent.`);
|
|
309
|
+
case "not_found":
|
|
310
|
+
return undefined;
|
|
311
|
+
case "self":
|
|
312
|
+
return peerRefusal("peer_send.invalid_target", `"${inlineUntrusted(to, 120)}" is this session — a session cannot send a cross-session message to itself.`);
|
|
313
|
+
case "deleted":
|
|
314
|
+
return peerRefusal("peer_send.invalid_target", `peer session "${inlineUntrusted(r.candidate.record.name, 64)}" was deleted — its address no longer accepts messages. Nothing was parked.`);
|
|
315
|
+
case "stale_ref":
|
|
316
|
+
return peerRefusal("peer_send.invalid_target", `the ref [${r.ref}] no longer resolves (the peer set changed since you listed it) — re-run ListAgents and re-send with a current ref.`);
|
|
317
|
+
case "ambiguous":
|
|
318
|
+
return { content: `Message not sent: ${inlineUntrusted(r.message, 1000)}`, details: { error: "ambiguous", to, candidates: r.candidates.map((c) => ({ name: c.record.name, ref: c.ref, address: peerSessionBoxHandle(c.record.sessionId), liveness: c.record.liveness })) }, isError: true };
|
|
319
|
+
case "found": {
|
|
320
|
+
const guard = pinGuard(peerSessionBoxHandle(r.candidate.record.sessionId), r.candidate.record.name, r.rung === "name" ? "name" : "ref", to);
|
|
321
|
+
if (guard !== undefined)
|
|
322
|
+
return guard;
|
|
323
|
+
return await withTargetLane(targetLaneKey(peerScope, peerSessionBoxHandle(r.candidate.record.sessionId)), () => deliverToPeerSession(r.candidate));
|
|
324
|
+
}
|
|
325
|
+
default: {
|
|
326
|
+
const _exhaustive = r;
|
|
327
|
+
void _exhaustive;
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
if (parsePeerSessionAddress(to) !== undefined) {
|
|
333
|
+
if (!peerLane.active) {
|
|
334
|
+
return peerRefusal("peer_send.invalid_target", peerLane.reason !== undefined ? `cross-session addressing is unavailable on this mount — ${peerLane.reason}. Nothing was sent.` : `cross-session addressing is not mounted here (no peer-session directory), so "${inlineUntrusted(to, 120)}" cannot be delivered.`);
|
|
335
|
+
}
|
|
336
|
+
const settled = await settlePeerResolution(await resolvePeerTarget());
|
|
337
|
+
return settled ?? peerRefusal("peer_send.invalid_target", `no session is registered at "${inlineUntrusted(to, 120)}" — nothing was parked (a message is only ever parked for a session that exists or existed).`);
|
|
338
|
+
}
|
|
206
339
|
if (normalizeAgentName(to) === "main") {
|
|
207
340
|
if (opts.uplink && senderId !== undefined) {
|
|
208
341
|
const uplinkIdentity = opts.uplinkRecipient?.current;
|
|
@@ -618,6 +751,14 @@ export function createSendMessageTool(opts) {
|
|
|
618
751
|
const labels = parentAccess !== undefined
|
|
619
752
|
? [...ownLabels, ...opts.registry.runningBackgroundAgentLabels(parentAccess).filter((l) => !ownLabels.includes(l))]
|
|
620
753
|
: ownLabels;
|
|
754
|
+
if (peerLane.active) {
|
|
755
|
+
const settled = await settlePeerResolution(await resolvePeerTarget());
|
|
756
|
+
if (settled !== undefined)
|
|
757
|
+
return settled;
|
|
758
|
+
}
|
|
759
|
+
else if (peerLane.reason !== undefined) {
|
|
760
|
+
return peerRefusal("peer_send.other", `no agent matches "${inlineUntrusted(to, 120)}", and the cross-session lane that could reach a peer session by that name is unavailable on this mount — ${peerLane.reason}.`);
|
|
761
|
+
}
|
|
621
762
|
if (DURABLE_AGENT_HANDLE_RE.test(to)) {
|
|
622
763
|
return {
|
|
623
764
|
content: `Message not sent: no transcript found for agent ${to} — launch a new agent with the needed context instead.`,
|
|
@@ -41,6 +41,17 @@ export interface SubagentEditedFile {
|
|
|
41
41
|
}
|
|
42
42
|
/** Keep the last N tool steps (CC's tail is ~this deep; older steps are rarely load-bearing for resume). */
|
|
43
43
|
export declare const STEP_CAP = 10;
|
|
44
|
+
/** Hard-bound a line to `max` UTF-16 units — a pure prefix, no marker appended. When the cut would split a
|
|
45
|
+
* redaction marker, it moves back to the marker's start (the bound holds, and the output never shows a
|
|
46
|
+
* marker fragment); when it would split a surrogate pair, it moves back one unit (never a lone surrogate). */
|
|
47
|
+
export declare function cutAt(line: string, max: number): string;
|
|
48
|
+
/**
|
|
49
|
+
* Redact, THEN bound — the order every display cut in this module (and the runner's `steering_injected`
|
|
50
|
+
* previews) uses. Exported so a preview minted elsewhere gets the same marker-aware cut rather than a
|
|
51
|
+
* raw `slice` that can leave a half marker at the bound. Keeps newlines: a multi-line body is the
|
|
52
|
+
* caller's shape; only the bound and the redaction are this function's.
|
|
53
|
+
*/
|
|
54
|
+
export declare function redactThenCut(s: string, max: number): string;
|
|
44
55
|
/** The tool's primary argument as a single short line (best-effort; unknown shapes → a compact JSON head). */
|
|
45
56
|
export declare function extractTarget(args: unknown): string;
|
|
46
57
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { redactSecrets } from "../core/untrusted-egress.js";
|
|
1
2
|
export const STEP_CAP = 10;
|
|
2
3
|
const FIELD_MAX = 80;
|
|
3
4
|
const EDITED_FILES_CAP = 32;
|
|
@@ -6,9 +7,31 @@ function firstLine(s) {
|
|
|
6
7
|
const nl = s.indexOf("\n");
|
|
7
8
|
return nl === -1 ? s : s.slice(0, nl);
|
|
8
9
|
}
|
|
10
|
+
const REDACTION_MARKER_RE = /\[redacted(?:-[a-z]+)?\]/g;
|
|
11
|
+
export function cutAt(line, max) {
|
|
12
|
+
if (line.length <= max)
|
|
13
|
+
return line;
|
|
14
|
+
let at = max;
|
|
15
|
+
for (const m of line.matchAll(REDACTION_MARKER_RE)) {
|
|
16
|
+
const start = m.index;
|
|
17
|
+
const end = start + m[0].length;
|
|
18
|
+
if (start < at && at < end) {
|
|
19
|
+
at = start;
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
if (start >= at)
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
const lead = line.charCodeAt(at - 1);
|
|
26
|
+
if (at > 0 && lead >= 0xd800 && lead <= 0xdbff)
|
|
27
|
+
at -= 1;
|
|
28
|
+
return line.slice(0, at);
|
|
29
|
+
}
|
|
30
|
+
export function redactThenCut(s, max) {
|
|
31
|
+
return cutAt(redactSecrets(s), max);
|
|
32
|
+
}
|
|
9
33
|
function clip(s) {
|
|
10
|
-
|
|
11
|
-
return line.length > FIELD_MAX ? line.slice(0, FIELD_MAX) : line;
|
|
34
|
+
return redactThenCut(firstLine(s).trim(), FIELD_MAX);
|
|
12
35
|
}
|
|
13
36
|
export function extractTarget(args) {
|
|
14
37
|
if (args === null || typeof args !== "object")
|
|
@@ -109,7 +132,7 @@ export class SubagentStepRecorder {
|
|
|
109
132
|
const tool = start?.tool ?? e.label ?? e.toolName;
|
|
110
133
|
const target = start?.target ?? "";
|
|
111
134
|
const body = outputFirstLine(e.output);
|
|
112
|
-
const outcome = e.isError ? `error: ${body}
|
|
135
|
+
const outcome = e.isError ? cutAt(`error: ${body}`, FIELD_MAX) : body;
|
|
113
136
|
this.steps.push({ tool, target, outcome });
|
|
114
137
|
if (this.steps.length > STEP_CAP)
|
|
115
138
|
this.steps.shift();
|
|
@@ -156,7 +179,7 @@ export function stepsFromMessages(messages, lastN) {
|
|
|
156
179
|
if (b.type !== "toolCall" || typeof b.name !== "string")
|
|
157
180
|
continue;
|
|
158
181
|
const res = typeof b.id === "string" ? results.get(b.id) : undefined;
|
|
159
|
-
const outcome = res ? (res.isError ? `error: ${res.body}
|
|
182
|
+
const outcome = res ? (res.isError ? cutAt(`error: ${res.body}`, FIELD_MAX) : res.body) : "";
|
|
160
183
|
steps.push({ tool: b.name, target: extractTarget(b.arguments), outcome });
|
|
161
184
|
}
|
|
162
185
|
}
|
|
@@ -29,6 +29,14 @@ export interface AutoModeArmingRecipe {
|
|
|
29
29
|
timeoutMs?: number;
|
|
30
30
|
/** Consecutive-failure threshold opening the one-way breaker (floored, as the decider itself floors it). */
|
|
31
31
|
failureThreshold?: number;
|
|
32
|
+
/**
|
|
33
|
+
* The cross-session lane's classifier rule was spliced into the assembled prompt (the lane was
|
|
34
|
+
* mounted on the arming leg). Part of the PROMPT BODY: the rebuild re-splices the same engine
|
|
35
|
+
* constant when the bit is set, so the recorded `promptDigest` is reproducible, and the fold treats
|
|
36
|
+
* the bit as body (a rebuild under a deployment whose own face does not declare it refuses as
|
|
37
|
+
* `settings_moved` — the two prompts differ by a rule block). Absent = the rule was not spliced.
|
|
38
|
+
*/
|
|
39
|
+
crossSessionMessagesRule?: true;
|
|
32
40
|
/**
|
|
33
41
|
* A digest of the EXACT classifier system prompt this arming assembled (see
|
|
34
42
|
* `rebuildAutoModeDecider`). The recipe records the deployment's OVERRIDES; the bulk of the criteria —
|
|
@@ -57,6 +65,9 @@ export interface AutoModeArmingFace {
|
|
|
57
65
|
timeoutMs?: number;
|
|
58
66
|
failureThreshold?: number;
|
|
59
67
|
settingsEpoch?: string;
|
|
68
|
+
/** `true` when the cross-session lane's classifier rule is spliced into this deployment's classifier
|
|
69
|
+
* prompt (the Runner sets it from its own lane mount; a redeeming host declares it from its). */
|
|
70
|
+
crossSessionMessagesRule?: boolean;
|
|
60
71
|
}
|
|
61
72
|
/**
|
|
62
73
|
* Canonicalize + VALIDATE an arming recipe: the plain-data form that persists, or `undefined` when the
|
|
@@ -81,9 +81,13 @@ export function sanitizeAutoModeArmingRecipe(value) {
|
|
|
81
81
|
const promptDigest = value.promptDigest;
|
|
82
82
|
if (promptDigest !== undefined && (typeof promptDigest !== "string" || promptDigest === ""))
|
|
83
83
|
return undefined;
|
|
84
|
+
const crossSessionMessagesRule = value.crossSessionMessagesRule;
|
|
85
|
+
if (crossSessionMessagesRule !== undefined && typeof crossSessionMessagesRule !== "boolean")
|
|
86
|
+
return undefined;
|
|
84
87
|
return {
|
|
85
88
|
v: AUTO_MODE_ARMING_RECIPE_VERSION,
|
|
86
89
|
...(promptDigest !== undefined ? { promptDigest } : {}),
|
|
90
|
+
...(crossSessionMessagesRule === true ? { crossSessionMessagesRule: true } : {}),
|
|
87
91
|
...(rules !== undefined ? { rules } : {}),
|
|
88
92
|
...(settingsDenyRules !== undefined ? { settingsDenyRules } : {}),
|
|
89
93
|
...(sessionContext !== undefined ? { sessionContext } : {}),
|
|
@@ -104,6 +108,7 @@ export function autoModeArmingRecipeOf(face, bind) {
|
|
|
104
108
|
...(face.timeoutMs !== undefined ? { timeoutMs: face.timeoutMs } : {}),
|
|
105
109
|
...(face.failureThreshold !== undefined ? { failureThreshold: face.failureThreshold } : {}),
|
|
106
110
|
...(face.settingsEpoch !== undefined ? { settingsEpoch: face.settingsEpoch } : {}),
|
|
111
|
+
...(face.crossSessionMessagesRule !== undefined ? { crossSessionMessagesRule: face.crossSessionMessagesRule } : {}),
|
|
107
112
|
});
|
|
108
113
|
}
|
|
109
114
|
function sameArmingBody(a, b) {
|
|
@@ -116,6 +121,7 @@ function sameArmingBody(a, b) {
|
|
|
116
121
|
r.sessionContext ?? null,
|
|
117
122
|
r.window?.maxEntries ?? null,
|
|
118
123
|
r.window?.maxCharsPerEntry ?? null,
|
|
124
|
+
r.crossSessionMessagesRule === true,
|
|
119
125
|
]);
|
|
120
126
|
return body(a) === body(b);
|
|
121
127
|
}
|
|
@@ -160,7 +166,7 @@ export function foldAutoModeArming(recorded, current) {
|
|
|
160
166
|
ok: false,
|
|
161
167
|
reason: "settings_moved",
|
|
162
168
|
message: "the auto-mode settings moved between the recorded arming and this deployment's current ones (rule sections / settings-deny rules / " +
|
|
163
|
-
"session context / window bounds differ), and free-text rule sets have no sound stricter-than ordering — refusing to rebuild " +
|
|
169
|
+
"session context / window bounds / the cross-session lane rule differ), and free-text rule sets have no sound stricter-than ordering — refusing to rebuild " +
|
|
164
170
|
"(the strictest decidable answer: the inherited ask flows the original chain to a human)",
|
|
165
171
|
};
|
|
166
172
|
}
|
|
@@ -22,6 +22,11 @@ export interface BuildAutoModePromptOptions {
|
|
|
22
22
|
/** Extra session-context facts (e.g. the CC user-identity line) — appended as a
|
|
23
23
|
* `## Session Context` bullet block after the assembled document. */
|
|
24
24
|
sessionContext?: readonly string[];
|
|
25
|
+
/** design/385 §4.5 — the text spliced into the `<cross_session_messages_rule>` slot. Absent (the
|
|
26
|
+
* default, and every deployment without the cross-session lane) ⇒ the slot is blanked exactly as
|
|
27
|
+
* before; the lane mount passes `CROSS_SESSION_CLASSIFIER_RULE`. Callback form at the splice (the
|
|
28
|
+
* text may carry `$`). */
|
|
29
|
+
crossSessionMessagesRule?: string;
|
|
25
30
|
}
|
|
26
31
|
/**
|
|
27
32
|
* Assemble the classifier SYSTEM prompt (CC `KRg`, content-equivalent single string — CC splits the
|
|
@@ -34,7 +34,8 @@ const PAIRED_SECTIONS = [
|
|
|
34
34
|
["environment", /<user_environment_to_replace>([\s\S]*?)<\/user_environment_to_replace>/],
|
|
35
35
|
];
|
|
36
36
|
export function buildAutoModePrompt(options) {
|
|
37
|
-
|
|
37
|
+
const crossSessionRule = options?.crossSessionMessagesRule ?? "";
|
|
38
|
+
let out = AUTO_MODE_BASE_PROMPT.replace("<permissions_template>", () => AUTO_MODE_PERMISSIONS_EXTERNAL).replace("<cross_session_messages_rule>", () => crossSessionRule);
|
|
38
39
|
for (const [key, re] of PAIRED_SECTIONS) {
|
|
39
40
|
out = out.replace(re, (_m, inner) => mergeRuleSection(options?.rules?.[key], inner));
|
|
40
41
|
}
|
|
@@ -58,7 +58,8 @@ export type AutoModeRebuildResult = {
|
|
|
58
58
|
* Rebuild a decider with a PARKED ancestor's criteria from its recorded recipe plus a fresh model leg.
|
|
59
59
|
*
|
|
60
60
|
* What is reproduced: the assembled system prompt (`buildAutoModePrompt` over the recorded rule
|
|
61
|
-
* overrides / settings-deny rules / session context
|
|
61
|
+
* overrides / settings-deny rules / session context, plus the engine's cross-session lane rule when
|
|
62
|
+
* the recipe says the arming leg spliced it), the transcript-window bounds, the round-trip
|
|
62
63
|
* timeout and the breaker threshold — i.e. every input the ancestor's own `createAutoModeDecider` call
|
|
63
64
|
* had except the model leg and the alarm closure.
|
|
64
65
|
*
|
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { createAutoModeDecider } from "./auto-mode.js";
|
|
3
3
|
import { buildAutoModePrompt, renderAutoModeAction, renderAutoModeWindow } from "./auto-mode-prompt.js";
|
|
4
4
|
import { foldAutoModeArming, } from "./auto-mode-arming.js";
|
|
5
|
+
import { CROSS_SESSION_CLASSIFIER_RULE } from "../agents/cross-session-envelope.js";
|
|
5
6
|
export function rebuildAutoModeDecider(opts) {
|
|
6
7
|
const folded = foldAutoModeArming(opts.recorded, opts.current);
|
|
7
8
|
if (!folded.ok)
|
|
@@ -11,6 +12,7 @@ export function rebuildAutoModeDecider(opts) {
|
|
|
11
12
|
...(effective.rules !== undefined ? { rules: effective.rules } : {}),
|
|
12
13
|
...(effective.settingsDenyRules !== undefined ? { settingsDenyRules: effective.settingsDenyRules } : {}),
|
|
13
14
|
...(effective.sessionContext !== undefined ? { sessionContext: effective.sessionContext } : {}),
|
|
15
|
+
...(effective.crossSessionMessagesRule === true ? { crossSessionMessagesRule: CROSS_SESSION_CLASSIFIER_RULE } : {}),
|
|
14
16
|
};
|
|
15
17
|
const systemPrompt = buildAutoModePrompt(promptOptions);
|
|
16
18
|
const assembledDigest = `apv1:${createHash("sha256").update(systemPrompt).digest("hex")}`;
|
|
@@ -1065,6 +1065,20 @@ export interface CheckpointState {
|
|
|
1065
1065
|
rules: SessionPermissionRules;
|
|
1066
1066
|
}>;
|
|
1067
1067
|
shellGate?: "off" | "always" | "classify";
|
|
1068
|
+
/** The chain's AUTO-MODE INTENT at suspend (data half, same law as `shellGate`): `true` when the
|
|
1069
|
+
* suspended leg was an auto-mode task — its own seat, the bit its live chain carried, or the bit
|
|
1070
|
+
* an earlier suspend of the same chain recorded (carried forward across a re-suspend). A resume
|
|
1071
|
+
* leg reads it as one more INTENT source beside the re-supplied seat and the waking chain, so a
|
|
1072
|
+
* redemption in another process (no seat re-passed, no live chain) still arms exactly as the
|
|
1073
|
+
* suspend leg did. It is a MEMORY of intent, never an authorization: the resuming deployment's
|
|
1074
|
+
* face (`RunnerDeps.autoMode`) and the resuming principal's deny bit (`RuntimeCaps.autoMode`)
|
|
1075
|
+
* are judged afresh on every leg — a bit on the row cannot arm where the redeeming deployment
|
|
1076
|
+
* would not. Follows the classifier's latch: a leg armed here writes it only while its own breaker
|
|
1077
|
+
* is untripped and untouched (a session that fell back to non-auto hands nothing forward); a leg
|
|
1078
|
+
* never armed here carries the memory as is. Absent on older checkpoints and on non-auto tasks
|
|
1079
|
+
* (byte-identical to the pre-bit row); an older worker that ignores it resumes un-armed, the
|
|
1080
|
+
* narrower direction. */
|
|
1081
|
+
autoModeRequested?: true;
|
|
1068
1082
|
/** Org-memory admission freeze (ruled 2026-08-05): the chain's admitted org-scope set at
|
|
1069
1083
|
* suspend (data half, plain strings). The resume leg folds it seed ∩ live (tighten-only) and
|
|
1070
1084
|
* re-runs admission under it — a resume must never widen the delegation freeze. Absent on
|
|
@@ -2,7 +2,8 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { uuidv7 } from "../internal/harness.js";
|
|
3
3
|
import { PROBE_CAUSE_PATH_MAX, inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
|
|
4
4
|
import { carriesBidiControls } from "./tool-policy.js";
|
|
5
|
-
import { renderUntrustedCommandText } from "./permission-rule-model.js";
|
|
5
|
+
import { renderUntrustedCommandText, stripFormatCharacters } from "./permission-rule-model.js";
|
|
6
|
+
import { redactSecrets } from "./untrusted-egress.js";
|
|
6
7
|
import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
|
|
7
8
|
export function mintCheckpointToken() {
|
|
8
9
|
return randomBytes(16).toString("hex");
|
|
@@ -124,7 +125,7 @@ export function buildRiskDescriptor(input) {
|
|
|
124
125
|
if (shell || toolName === "Bash") {
|
|
125
126
|
const cmd = isPlainRecord(args) ? safeDataValue(args, "command") : undefined;
|
|
126
127
|
if (typeof cmd === "string" && cmd.length > 0)
|
|
127
|
-
summary = renderUntrustedCommandText(cmd, SUMMARY_CMD_MAX);
|
|
128
|
+
summary = renderUntrustedCommandText(redactSecrets(stripFormatCharacters(cmd)), SUMMARY_CMD_MAX);
|
|
128
129
|
const bg = isPlainRecord(args) ? safeDataValue(args, "run_in_background") : undefined;
|
|
129
130
|
if (bg === true)
|
|
130
131
|
summary = `[background persistent process — no per-step recheck] ${summary ?? ""}`.trimEnd();
|
|
@@ -139,7 +140,7 @@ export function buildRiskDescriptor(input) {
|
|
|
139
140
|
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
|
|
140
141
|
const encDigest = (s) => s.replace(/%/g, "%25").replace(/=/g, "%3D").replace(/ /g, "%20");
|
|
141
142
|
const k = encDigest(renderUntrustedCommandText(key, 40));
|
|
142
|
-
const val = encDigest(renderUntrustedCommandText(String(v), SUMMARY_VALUE_MAX));
|
|
143
|
+
const val = encDigest(renderUntrustedCommandText(redactSecrets(stripFormatCharacters(String(v))), SUMMARY_VALUE_MAX));
|
|
143
144
|
parts.push(`${k}=${val}`);
|
|
144
145
|
}
|
|
145
146
|
}
|
|
@@ -103,7 +103,7 @@ export type NoticeAudience = "user" | "operator";
|
|
|
103
103
|
* src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
|
|
104
104
|
* no longer minted.
|
|
105
105
|
*/
|
|
106
|
-
export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
|
|
106
|
+
export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.durable_gate_unavailable", "config.peer_lane_unmounted", "peer.inbound_disposition", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
|
|
107
107
|
/** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
|
|
108
108
|
* `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
|
|
109
109
|
* sink is a supported shape, and narrowing that field would break it. */
|
|
@@ -101,6 +101,9 @@ export const ENGINE_NOTICE_CODES = [
|
|
|
101
101
|
"config.tool_model_gate_removed",
|
|
102
102
|
"config.tool_model_gate_unknown_class",
|
|
103
103
|
"config.tool_model_gate_env_invalid",
|
|
104
|
+
"config.durable_gate_unavailable",
|
|
105
|
+
"config.peer_lane_unmounted",
|
|
106
|
+
"peer.inbound_disposition",
|
|
104
107
|
"delegation.transcript_integrity",
|
|
105
108
|
"mcp.revocation_probe_failed",
|
|
106
109
|
"workflow.governance_key_stripped",
|
|
@@ -143,6 +146,7 @@ const NOTICE_AUDIENCE_TABLE = {
|
|
|
143
146
|
"steering.parked_input_blocked": "user",
|
|
144
147
|
"task.halt_unconsumed": "user",
|
|
145
148
|
"task.late_approval": "user",
|
|
149
|
+
"config.durable_gate_unavailable": "user",
|
|
146
150
|
"memory.capture_opted_out": "user",
|
|
147
151
|
"memory.capture_optout_unpersisted": "user",
|
|
148
152
|
"memory.consolidation_withheld": "user",
|
|
@@ -154,6 +158,8 @@ const NOTICE_AUDIENCE_TABLE = {
|
|
|
154
158
|
"config.tool_model_gate_removed": "operator",
|
|
155
159
|
"config.tool_model_gate_unknown_class": "operator",
|
|
156
160
|
"config.tool_model_gate_env_invalid": "operator",
|
|
161
|
+
"config.peer_lane_unmounted": "operator",
|
|
162
|
+
"peer.inbound_disposition": "user",
|
|
157
163
|
"delegation.transcript_integrity": "operator",
|
|
158
164
|
"mcp.revocation_probe_failed": "operator",
|
|
159
165
|
"workflow.governance_key_stripped": "operator",
|