agent-relay-runner 0.53.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
CHANGED
|
@@ -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> {
|