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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.53.0",
4
+ "version": "0.54.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -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
- // The outbox transport: map a queued record to its HTTP call. Throw to retry, return to
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.recordContinuationArchive({
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> {