agent-relay-runner 0.52.0 → 0.54.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/package.json +1 -1
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapters/codex.ts +34 -0
- package/src/continuation-archive.ts +176 -0
- package/src/outbox.ts +5 -0
- package/src/runner.ts +7 -9
package/package.json
CHANGED
package/src/adapters/codex.ts
CHANGED
|
@@ -392,6 +392,21 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
392
392
|
updatedAt: Date.now(),
|
|
393
393
|
},
|
|
394
394
|
});
|
|
395
|
+
} else if (event.message.method === CODEX_MCP_ELICITATION_METHOD && isCodexHeadless(process)) {
|
|
396
|
+
// Codex routes per-MCP-tool-call approval through the MCP elicitation channel
|
|
397
|
+
// (request_id `mcp_tool_call_approval_*`): even with `approval_policy="never"`, a
|
|
398
|
+
// downstream server's elicitation — or codex's own per-call approval gate — is
|
|
399
|
+
// forwarded to the app-server client as `mcpServer/elicitation/request`. A headless
|
|
400
|
+
// managed agent has no human to answer it; the old -32601 reject makes codex resolve
|
|
401
|
+
// the elicitation as `Decline`, so the MCP tool call dies with "user rejected MCP tool
|
|
402
|
+
// call" — stranding read-only recon/reviewer workers with no egress to report back
|
|
403
|
+
// over the bus (#395). The agent is already bounded by its scoped relay token + the
|
|
404
|
+
// codex sandbox, so auto-accept (spec-compliant `{ action: "accept" }`, not codex's
|
|
405
|
+
// legacy `{ decision: "approved" }`) so the agent's reporting/recon MCP tools run.
|
|
406
|
+
const client = process.meta?.client as CodexAppClient | undefined;
|
|
407
|
+
const serverName = isRecord(event.message.params) ? stringValue(event.message.params.serverName) : undefined;
|
|
408
|
+
logger.info("codex", `auto-accepting headless MCP elicitation from ${serverName ?? "unknown server"} so the agent's MCP tools can run (#395)`);
|
|
409
|
+
client?.respondToServerRequest(event.message.id, codexHeadlessElicitationResponse());
|
|
395
410
|
} else {
|
|
396
411
|
const client = process.meta?.client as CodexAppClient | undefined;
|
|
397
412
|
logger.warn("codex", `rejecting unknown Codex server-request method: ${event.message.method}`);
|
|
@@ -683,6 +698,25 @@ function codexItemTypeFromMethod(method: string): string | undefined {
|
|
|
683
698
|
return match?.[1];
|
|
684
699
|
}
|
|
685
700
|
|
|
701
|
+
// Codex forwards MCP elicitation (incl. its per-tool-call approval gate) to the app-server
|
|
702
|
+
// client under this server-request method (#395). See handleCodexEvent for why a headless
|
|
703
|
+
// agent must answer it instead of rejecting it.
|
|
704
|
+
export const CODEX_MCP_ELICITATION_METHOD = "mcpServer/elicitation/request";
|
|
705
|
+
|
|
706
|
+
// A managed agent with no attended TUI (the spawn intent, `config.headless`) has no human to
|
|
707
|
+
// resolve an elicitation. Such agents are the #395 case; interactive (TUI) sessions leave the
|
|
708
|
+
// elicitation to the human in the attached terminal.
|
|
709
|
+
function isCodexHeadless(process: ManagedProcess): boolean {
|
|
710
|
+
return (process.meta?.config as RunnerSpawnConfig | undefined)?.headless === true;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Spec-compliant auto-accept for a headless agent's MCP elicitation. `action` (not codex's
|
|
714
|
+
// legacy `decision`) is the load-bearing field; `content: {}` approves codex's per-tool-call
|
|
715
|
+
// gate and any no-required-field form without fabricating runtime field values for the agent.
|
|
716
|
+
export function codexHeadlessElicitationResponse(): Record<string, unknown> {
|
|
717
|
+
return { action: "accept", content: {}, _meta: null };
|
|
718
|
+
}
|
|
719
|
+
|
|
686
720
|
function codexApprovalFromServerRequest(message: { id: string | number; method: string; params?: unknown }): { pending: PendingCodexApproval; view: Record<string, unknown> } | null {
|
|
687
721
|
if (!isRecord(message.params)) return null;
|
|
688
722
|
const method = message.method;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { RelayHttpClient } from "agent-relay-sdk";
|
|
2
|
+
import type { OutboxRecord } from "./outbox";
|
|
3
|
+
|
|
4
|
+
const CONTINUATION_ARCHIVE_MAX_POST_BODY_BYTES = 64 * 1024;
|
|
5
|
+
const CONTINUATION_ARCHIVE_CHUNK_TARGET_BYTES = 56 * 1024;
|
|
6
|
+
const CONTINUATION_ARCHIVE_MAX_GENERATION_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
interface ContinuationArchiveOutboxPayload {
|
|
9
|
+
agentId: string;
|
|
10
|
+
segment: string;
|
|
11
|
+
generation?: number;
|
|
12
|
+
deliveredChunks?: number;
|
|
13
|
+
totalChunks?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ContinuationArchiveResponse {
|
|
17
|
+
archive?: { generation?: unknown };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function boundContinuationArchiveSegment(segment: string): { segment: string; keptBytes: number; droppedBytes: number } {
|
|
21
|
+
const bytes = utf8Bytes(segment);
|
|
22
|
+
if (bytes <= CONTINUATION_ARCHIVE_MAX_GENERATION_BYTES) {
|
|
23
|
+
return { segment, keptBytes: bytes, droppedBytes: 0 };
|
|
24
|
+
}
|
|
25
|
+
const bounded = takeUtf8Prefix(segment, CONTINUATION_ARCHIVE_MAX_GENERATION_BYTES);
|
|
26
|
+
const keptBytes = utf8Bytes(bounded);
|
|
27
|
+
return { segment: bounded.trimEnd(), keptBytes, droppedBytes: bytes - keptBytes };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function deliverContinuationArchiveRecord(input: {
|
|
31
|
+
record: OutboxRecord;
|
|
32
|
+
http: Pick<RelayHttpClient, "recordContinuationArchive">;
|
|
33
|
+
updatePayload: (seq: number, payload: unknown) => void;
|
|
34
|
+
sessionLog: (message: string) => void;
|
|
35
|
+
}): Promise<void> {
|
|
36
|
+
const payload = input.record.payload as ContinuationArchiveOutboxPayload;
|
|
37
|
+
let segment = payload.segment;
|
|
38
|
+
if (!payload.agentId || typeof segment !== "string") throw new Error("invalid continuation archive outbox payload");
|
|
39
|
+
|
|
40
|
+
const bounded = boundContinuationArchiveSegment(segment);
|
|
41
|
+
if (bounded.droppedBytes > 0) {
|
|
42
|
+
segment = bounded.segment;
|
|
43
|
+
input.sessionLog(`continuation archive truncated at ${bounded.keptBytes} bytes; dropped ${bounded.droppedBytes} bytes before delivery`);
|
|
44
|
+
input.updatePayload(input.record.seq, { ...payload, segment, deliveredChunks: 0, totalChunks: undefined, generation: undefined });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const chunks = splitContinuationArchiveSegment({
|
|
48
|
+
agentId: payload.agentId,
|
|
49
|
+
segment,
|
|
50
|
+
occurredAt: input.record.occurredAt,
|
|
51
|
+
});
|
|
52
|
+
const knownGeneration = Number.isSafeInteger(payload.generation) ? payload.generation : undefined;
|
|
53
|
+
const deliveredChunks = knownGeneration === undefined ? 0 : Math.max(0, Math.min(payload.deliveredChunks ?? 0, chunks.length));
|
|
54
|
+
let generation = knownGeneration;
|
|
55
|
+
|
|
56
|
+
for (let index = deliveredChunks; index < chunks.length; index += 1) {
|
|
57
|
+
const request = {
|
|
58
|
+
agentId: payload.agentId,
|
|
59
|
+
segment: chunks[index]!,
|
|
60
|
+
occurredAt: input.record.occurredAt,
|
|
61
|
+
...(generation !== undefined ? { generation } : {}),
|
|
62
|
+
};
|
|
63
|
+
assertContinuationArchivePostFits(request);
|
|
64
|
+
const response = await input.http.recordContinuationArchive(request) as ContinuationArchiveResponse;
|
|
65
|
+
const returnedGeneration = archiveGeneration(response);
|
|
66
|
+
if (generation === undefined) {
|
|
67
|
+
if (returnedGeneration === undefined) throw new Error("continuation archive response missing generation");
|
|
68
|
+
generation = returnedGeneration;
|
|
69
|
+
} else if (returnedGeneration !== undefined && returnedGeneration !== generation) {
|
|
70
|
+
throw new Error(`continuation archive generation mismatch: expected ${generation}, got ${returnedGeneration}`);
|
|
71
|
+
}
|
|
72
|
+
input.updatePayload(input.record.seq, {
|
|
73
|
+
...payload,
|
|
74
|
+
segment,
|
|
75
|
+
generation,
|
|
76
|
+
deliveredChunks: index + 1,
|
|
77
|
+
totalChunks: chunks.length,
|
|
78
|
+
} satisfies ContinuationArchiveOutboxPayload);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function splitContinuationArchiveSegment(input: { agentId: string; segment: string; occurredAt: number }): string[] {
|
|
83
|
+
const chunks: string[] = [];
|
|
84
|
+
let current = "";
|
|
85
|
+
for (const line of transcriptLines(input.segment)) {
|
|
86
|
+
if (!line) continue;
|
|
87
|
+
if (fitsContinuationArchiveChunk(input.agentId, line, input.occurredAt)) {
|
|
88
|
+
const next = current ? current + line : line;
|
|
89
|
+
if (utf8Bytes(next) <= CONTINUATION_ARCHIVE_CHUNK_TARGET_BYTES && fitsContinuationArchiveChunk(input.agentId, next, input.occurredAt)) {
|
|
90
|
+
current = next;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (current) chunks.push(current.trimEnd());
|
|
94
|
+
current = line;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (current) {
|
|
98
|
+
chunks.push(current.trimEnd());
|
|
99
|
+
current = "";
|
|
100
|
+
}
|
|
101
|
+
chunks.push(...splitLongContinuationArchiveLine(input.agentId, line, input.occurredAt));
|
|
102
|
+
}
|
|
103
|
+
if (current) chunks.push(current.trimEnd());
|
|
104
|
+
return chunks.filter((chunk) => chunk.length > 0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function transcriptLines(segment: string): string[] {
|
|
108
|
+
const lines = segment.match(/[^\n]*(?:\n|$)/g) ?? [segment];
|
|
109
|
+
return lines.filter((line) => line.length > 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function splitLongContinuationArchiveLine(agentId: string, line: string, occurredAt: number): string[] {
|
|
113
|
+
const chars = Array.from(line);
|
|
114
|
+
const chunks: string[] = [];
|
|
115
|
+
let offset = 0;
|
|
116
|
+
while (offset < chars.length) {
|
|
117
|
+
let low = 1;
|
|
118
|
+
let high = chars.length - offset;
|
|
119
|
+
let best = 0;
|
|
120
|
+
while (low <= high) {
|
|
121
|
+
const mid = Math.floor((low + high) / 2);
|
|
122
|
+
const candidate = chars.slice(offset, offset + mid).join("");
|
|
123
|
+
if (utf8Bytes(candidate) <= CONTINUATION_ARCHIVE_CHUNK_TARGET_BYTES && fitsContinuationArchiveChunk(agentId, candidate, occurredAt)) {
|
|
124
|
+
best = mid;
|
|
125
|
+
low = mid + 1;
|
|
126
|
+
} else {
|
|
127
|
+
high = mid - 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (best === 0) throw new Error("continuation archive chunk budget cannot fit one character");
|
|
131
|
+
chunks.push(chars.slice(offset, offset + best).join(""));
|
|
132
|
+
offset += best;
|
|
133
|
+
}
|
|
134
|
+
return chunks;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function fitsContinuationArchiveChunk(agentId: string, segment: string, occurredAt: number): boolean {
|
|
138
|
+
return continuationArchivePostBytes({
|
|
139
|
+
agentId,
|
|
140
|
+
segment,
|
|
141
|
+
occurredAt,
|
|
142
|
+
generation: Number.MAX_SAFE_INTEGER,
|
|
143
|
+
}) < CONTINUATION_ARCHIVE_MAX_POST_BODY_BYTES;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function assertContinuationArchivePostFits(input: { agentId: string; segment: string; occurredAt: number; generation?: number }): void {
|
|
147
|
+
const bytes = continuationArchivePostBytes(input);
|
|
148
|
+
if (bytes >= CONTINUATION_ARCHIVE_MAX_POST_BODY_BYTES) {
|
|
149
|
+
throw new Error(`continuation archive chunk JSON body is ${bytes} bytes; max is ${CONTINUATION_ARCHIVE_MAX_POST_BODY_BYTES - 1}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function continuationArchivePostBytes(input: { agentId: string; segment: string; occurredAt: number; generation?: number }): number {
|
|
154
|
+
return utf8Bytes(JSON.stringify(input));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function archiveGeneration(response: ContinuationArchiveResponse): number | undefined {
|
|
158
|
+
const generation = response.archive?.generation;
|
|
159
|
+
return typeof generation === "number" && Number.isSafeInteger(generation) && generation >= 0 ? generation : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function takeUtf8Prefix(value: string, maxBytes: number): string {
|
|
163
|
+
let used = 0;
|
|
164
|
+
let out = "";
|
|
165
|
+
for (const char of value) {
|
|
166
|
+
const bytes = utf8Bytes(char);
|
|
167
|
+
if (used + bytes > maxBytes) break;
|
|
168
|
+
out += char;
|
|
169
|
+
used += bytes;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function utf8Bytes(value: string): number {
|
|
175
|
+
return new TextEncoder().encode(value).byteLength;
|
|
176
|
+
}
|
package/src/outbox.ts
CHANGED
|
@@ -298,6 +298,11 @@ export class Outbox {
|
|
|
298
298
|
return (this.db.query("SELECT count(*) AS n FROM outbox WHERE poisoned = 0").get() as { n: number }).n;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
updatePayload(seq: number, payload: unknown): void {
|
|
302
|
+
const payloadJson = JSON.stringify(payload ?? null);
|
|
303
|
+
this.db.query("UPDATE outbox SET payload = ? WHERE seq = ? AND poisoned = 0").run(payloadJson, seq);
|
|
304
|
+
}
|
|
305
|
+
|
|
301
306
|
poisonedCount(): number {
|
|
302
307
|
return (this.db.query("SELECT count(*) AS n FROM outbox WHERE poisoned = 1").get() as { n: number }).n;
|
|
303
308
|
}
|
package/src/runner.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { RelayMcpProxy } from "./relay-mcp-proxy";
|
|
|
21
21
|
import { runtimeMetadata } from "./version";
|
|
22
22
|
import { logger, parseLogLevel } from "./logger";
|
|
23
23
|
import { ensureSessionScratch, reapSessionScratch, sweepStaleSessions, type SessionScratchLayout } from "./session-scratch";
|
|
24
|
+
import { boundContinuationArchiveSegment, deliverContinuationArchiveRecord } from "./continuation-archive";
|
|
24
25
|
|
|
25
26
|
// A destructive session transition. The runner runs end-of-session work (Insights
|
|
26
27
|
// capture, #183/#184) before the invasive operation and, during that window, presents a
|
|
@@ -1289,9 +1290,7 @@ export class AgentRunner {
|
|
|
1289
1290
|
});
|
|
1290
1291
|
}
|
|
1291
1292
|
|
|
1292
|
-
//
|
|
1293
|
-
// ack (delete). occurredAt + idempotencyKey are injected from the record so retries are
|
|
1294
|
-
// exactly-once server-side and carry true event time.
|
|
1293
|
+
// Map queued records to HTTP calls. Throw to retry, return to ack/delete.
|
|
1295
1294
|
private async deliverOutboxEvent(record: OutboxRecord): Promise<void> {
|
|
1296
1295
|
try {
|
|
1297
1296
|
if (record.kind === "session-message") {
|
|
@@ -1310,10 +1309,7 @@ export class AgentRunner {
|
|
|
1310
1309
|
return;
|
|
1311
1310
|
}
|
|
1312
1311
|
if (record.kind === "continuation-archive") {
|
|
1313
|
-
await this.http.
|
|
1314
|
-
...(record.payload as Parameters<RelayHttpClient["recordContinuationArchive"]>[0]),
|
|
1315
|
-
occurredAt: record.occurredAt,
|
|
1316
|
-
});
|
|
1312
|
+
await deliverContinuationArchiveRecord({ record, http: this.http, updatePayload: (seq, payload) => this.outbox.updatePayload(seq, payload), sessionLog: (message) => this.sessionLog(message) });
|
|
1317
1313
|
return;
|
|
1318
1314
|
}
|
|
1319
1315
|
if (record.kind === "mcp-tool-call") {
|
|
@@ -1510,14 +1506,16 @@ export class AgentRunner {
|
|
|
1510
1506
|
const segment = archive.slice(this.archiveObservedChars).trim();
|
|
1511
1507
|
this.archiveObservedChars = archive.length;
|
|
1512
1508
|
if (!segment) return;
|
|
1509
|
+
const bounded = boundContinuationArchiveSegment(segment);
|
|
1510
|
+
if (bounded.droppedBytes > 0) this.sessionLog(`continuation archive truncated at ${bounded.keptBytes} bytes; dropped ${bounded.droppedBytes} bytes (${reason})`);
|
|
1513
1511
|
this.outbox.enqueue({
|
|
1514
1512
|
kind: "continuation-archive",
|
|
1515
1513
|
payload: {
|
|
1516
1514
|
agentId: this.agentId,
|
|
1517
|
-
segment,
|
|
1515
|
+
segment: bounded.segment,
|
|
1518
1516
|
},
|
|
1519
1517
|
});
|
|
1520
|
-
this.sessionLog(`continuation archive queued (${segment.length} chars, ${reason})`);
|
|
1518
|
+
this.sessionLog(`continuation archive queued (${bounded.segment.length} chars, ${reason})`);
|
|
1521
1519
|
}
|
|
1522
1520
|
|
|
1523
1521
|
private async captureContextRatio(reason: SessionDestroyReason, opts?: { transcriptPath?: string }): Promise<void> {
|