agent-relay-runner 0.120.5 → 0.121.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.120.5",
3
+ "version": "0.121.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "agent-relay-providers": "0.104.3",
24
- "agent-relay-sdk": "0.2.110",
24
+ "agent-relay-sdk": "0.2.112",
25
25
  "callmux": "0.23.0"
26
26
  },
27
27
  "devDependencies": {
@@ -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.120.5",
4
+ "version": "0.121.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -0,0 +1,128 @@
1
+ import { open, stat } from "node:fs/promises";
2
+ import { LatestTurnStepAccumulator, type TurnStep } from "./claude-transcript";
3
+
4
+ const FINGERPRINT_BYTES = 4096;
5
+
6
+ export interface ClaudeTranscriptFileIdentity {
7
+ dev: number;
8
+ ino: number;
9
+ birthtimeMs: number;
10
+ }
11
+
12
+ interface TailFingerprint {
13
+ offset: number;
14
+ bytes: Buffer;
15
+ }
16
+
17
+ export interface IncrementalTranscriptPollResult {
18
+ available: boolean;
19
+ changed: boolean;
20
+ reset: boolean;
21
+ steps: TurnStep[];
22
+ transcriptLooksComplete: boolean;
23
+ }
24
+
25
+ export class IncrementalClaudeTranscriptTail {
26
+ private offset = 0;
27
+ private pending = Buffer.alloc(0);
28
+ private identity: ClaudeTranscriptFileIdentity | undefined;
29
+ private fingerprint: TailFingerprint | undefined;
30
+ private readonly accumulator = new LatestTurnStepAccumulator();
31
+
32
+ async poll(path: string): Promise<IncrementalTranscriptPollResult> {
33
+ let fileStat: Awaited<ReturnType<typeof stat>>;
34
+ try {
35
+ fileStat = await stat(path);
36
+ } catch {
37
+ return this.result({ available: false, changed: false, reset: false });
38
+ }
39
+
40
+ const nextIdentity = { dev: fileStat.dev, ino: fileStat.ino, birthtimeMs: fileStat.birthtimeMs };
41
+ const replaced = this.identity !== undefined && !sameIdentity(this.identity, nextIdentity);
42
+ const truncated = this.offset > fileStat.size;
43
+ let reset = replaced || truncated;
44
+ if (!reset && this.identity && this.fingerprint && fileStat.size >= this.offset) {
45
+ reset = !(await this.fingerprintMatches(path));
46
+ }
47
+ if (reset) this.reset(nextIdentity);
48
+ else if (!this.identity) this.identity = nextIdentity;
49
+
50
+ if (this.offset === fileStat.size) {
51
+ return this.result({ available: true, changed: false, reset });
52
+ }
53
+
54
+ const length = fileStat.size - this.offset;
55
+ const buffer = Buffer.alloc(length);
56
+ let bytesRead = 0;
57
+ const handle = await open(path, "r");
58
+ try {
59
+ const read = await handle.read(buffer, 0, length, this.offset);
60
+ bytesRead = read.bytesRead;
61
+ } finally {
62
+ await handle.close();
63
+ }
64
+
65
+ this.offset += bytesRead;
66
+ const delta = buffer.subarray(0, bytesRead);
67
+ this.updateFingerprint(delta);
68
+ const changed = bytesRead > 0 && this.consume(delta);
69
+ return this.result({ available: true, changed, reset });
70
+ }
71
+
72
+ private reset(identity: ClaudeTranscriptFileIdentity): void {
73
+ this.offset = 0;
74
+ this.pending = Buffer.alloc(0);
75
+ this.fingerprint = undefined;
76
+ this.identity = identity;
77
+ this.accumulator.reset();
78
+ }
79
+
80
+ private async fingerprintMatches(path: string): Promise<boolean> {
81
+ if (!this.fingerprint) return true;
82
+ const buffer = Buffer.alloc(this.fingerprint.bytes.length);
83
+ const handle = await open(path, "r");
84
+ try {
85
+ const read = await handle.read(buffer, 0, buffer.length, this.fingerprint.offset);
86
+ return read.bytesRead === buffer.length && buffer.equals(this.fingerprint.bytes);
87
+ } finally {
88
+ await handle.close();
89
+ }
90
+ }
91
+
92
+ private updateFingerprint(delta: Buffer): void {
93
+ if (!delta.length) return;
94
+ const previous = this.fingerprint?.bytes ?? Buffer.alloc(0);
95
+ const combined = previous.length ? Buffer.concat([previous, delta]) : delta;
96
+ const bytes = Buffer.from(combined.subarray(Math.max(0, combined.length - FINGERPRINT_BYTES)));
97
+ this.fingerprint = { offset: this.offset - bytes.length, bytes };
98
+ }
99
+
100
+ private consume(delta: Buffer): boolean {
101
+ if (!delta.length) return false;
102
+ const combined = this.pending.length ? Buffer.concat([this.pending, delta]) : delta;
103
+ const lastNewline = combined.lastIndexOf(0x0a);
104
+ if (lastNewline === -1) {
105
+ this.pending = Buffer.from(combined);
106
+ return false;
107
+ }
108
+
109
+ const complete = combined.subarray(0, lastNewline + 1);
110
+ this.pending = Buffer.from(combined.subarray(lastNewline + 1));
111
+ this.accumulator.appendJsonl(complete.toString("utf8"));
112
+ return true;
113
+ }
114
+
115
+ private result(input: { available: boolean; changed: boolean; reset: boolean }): IncrementalTranscriptPollResult {
116
+ return {
117
+ ...input,
118
+ steps: this.accumulator.steps,
119
+ transcriptLooksComplete: this.accumulator.transcriptLooksComplete,
120
+ };
121
+ }
122
+ }
123
+
124
+ function sameIdentity(a: ClaudeTranscriptFileIdentity, b: ClaudeTranscriptFileIdentity): boolean {
125
+ return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
126
+ }
127
+
128
+ export const sameClaudeTranscriptFileIdentityForTests = sameIdentity;
@@ -21,7 +21,7 @@ interface TranscriptBlock {
21
21
  is_error?: boolean;
22
22
  }
23
23
 
24
- interface TurnStep {
24
+ export interface TurnStep {
25
25
  type: "narration" | "reasoning" | "tool";
26
26
  text: string;
27
27
  label?: string;
@@ -83,20 +83,9 @@ function assistantText(entry: TranscriptEntry): string {
83
83
  * assistant block is written to disk.
84
84
  */
85
85
  export function transcriptLooksComplete(jsonl: string): boolean {
86
- const lines = jsonl.split("\n");
87
- let lastAssistantStopReason: string | undefined;
88
- for (const line of lines) {
89
- const trimmed = line.trim();
90
- if (!trimmed) continue;
91
- try {
92
- const entry = JSON.parse(trimmed) as TranscriptEntry;
93
- if (isSidechainEntry(entry)) continue;
94
- if (entry.type === "assistant") lastAssistantStopReason = entry.message?.stop_reason;
95
- } catch {
96
- continue;
97
- }
98
- }
99
- return lastAssistantStopReason === "end_turn";
86
+ const state = new LatestTurnStepAccumulator();
87
+ state.appendJsonl(jsonl);
88
+ return state.transcriptLooksComplete;
100
89
  }
101
90
 
102
91
  /**
@@ -251,34 +240,65 @@ export function extractFinalAssistantMessageAfterEntry(jsonl: string, afterEntry
251
240
  * tailer can emit only the ones it hasn't seen yet.
252
241
  */
253
242
  export function extractLatestTurnSteps(jsonl: string): TurnStep[] {
254
- const lines = jsonl.split("\n");
255
- let steps: TurnStep[] = [];
256
- for (const line of lines) {
243
+ const state = new LatestTurnStepAccumulator();
244
+ state.appendJsonl(jsonl);
245
+ return state.steps;
246
+ }
247
+
248
+ export class LatestTurnStepAccumulator {
249
+ private latestSteps: TurnStep[] = [];
250
+ private lastAssistantStopReason: string | undefined;
251
+ private parsedEntries = 0;
252
+
253
+ get steps(): TurnStep[] {
254
+ return [...this.latestSteps];
255
+ }
256
+
257
+ get transcriptLooksComplete(): boolean {
258
+ return this.lastAssistantStopReason === "end_turn";
259
+ }
260
+
261
+ get entryCount(): number {
262
+ return this.parsedEntries;
263
+ }
264
+
265
+ reset(): void {
266
+ this.latestSteps = [];
267
+ this.lastAssistantStopReason = undefined;
268
+ this.parsedEntries = 0;
269
+ }
270
+
271
+ appendJsonl(jsonl: string): void {
272
+ for (const line of jsonl.split("\n")) this.appendLine(line);
273
+ }
274
+
275
+ appendLine(line: string): void {
257
276
  const trimmed = line.trim();
258
- if (!trimmed) continue;
277
+ if (!trimmed) return;
259
278
  let entry: TranscriptEntry;
260
279
  try {
261
280
  entry = JSON.parse(trimmed) as TranscriptEntry;
262
281
  } catch {
263
- continue;
282
+ return;
264
283
  }
265
- if (isSidechainEntry(entry)) continue;
284
+ this.parsedEntries += 1;
285
+ if (isSidechainEntry(entry)) return;
286
+ if (entry.type === "assistant") this.lastAssistantStopReason = entry.message?.stop_reason;
266
287
  if (isRealUserPrompt(entry)) {
267
- steps = [];
268
- continue;
288
+ this.latestSteps = [];
289
+ return;
269
290
  }
270
- if (entry.type !== "assistant") continue;
291
+ if (entry.type !== "assistant") return;
271
292
  for (const b of blocks(entry.message)) {
272
293
  if (b.type === "text" && typeof b.text === "string" && b.text.trim()) {
273
- steps.push({ type: "narration", text: b.text.trim() });
294
+ this.latestSteps.push({ type: "narration", text: b.text.trim() });
274
295
  } else if (b.type === "thinking" && typeof b.thinking === "string" && b.thinking.trim()) {
275
- steps.push({ type: "reasoning", text: b.thinking.trim() });
296
+ this.latestSteps.push({ type: "reasoning", text: b.thinking.trim() });
276
297
  } else if (b.type === "tool_use" && typeof b.name === "string" && b.name) {
277
- steps.push({ type: "tool", label: b.name, text: summarizeToolUse(b.name, b.input) });
298
+ this.latestSteps.push({ type: "tool", label: b.name, text: summarizeToolUse(b.name, b.input) });
278
299
  }
279
300
  }
280
301
  }
281
- return steps;
282
302
  }
283
303
 
284
304
  /**
package/src/mcp-outbox.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { SendMessageInput } from "agent-relay-sdk";
2
2
  import type { RelayHttpClient } from "agent-relay-sdk";
3
+ import { errMessage } from "agent-relay-sdk";
3
4
  import { relayMcpEndpoint } from "./relay-mcp";
4
5
  import type { OutboxRecord } from "./outbox";
5
6
  import { deliverContinuationArchiveRecord } from "./continuation-archive";
@@ -27,6 +28,23 @@ export async function deliverRunnerOutboxEvent(input: RunnerOutboxDelivery): Pro
27
28
  });
28
29
  return;
29
30
  }
31
+ if (record.kind === "session-message-batch") {
32
+ const messages = Array.isArray((record.payload as { messages?: unknown }).messages)
33
+ ? (record.payload as { messages: SendMessageInput[] }).messages
34
+ : [];
35
+ const batch = messages.map((message, index) => ({
36
+ ...message,
37
+ occurredAt: typeof message.occurredAt === "number" ? message.occurredAt : record.occurredAt,
38
+ idempotencyKey: message.idempotencyKey ?? `${record.idempotencyKey}:${index}`,
39
+ }));
40
+ try {
41
+ await http.sendMessages(batch);
42
+ } catch (error) {
43
+ if (!isDeterministicValidationError(error)) throw error;
44
+ await deliverSessionBatchItems(input, batch);
45
+ }
46
+ return;
47
+ }
30
48
  if (record.kind === "insight") {
31
49
  await http.recordInsightObservation({
32
50
  ...(record.payload as Parameters<RelayHttpClient["recordInsightObservation"]>[0]),
@@ -55,6 +73,22 @@ export async function deliverRunnerOutboxEvent(input: RunnerOutboxDelivery): Pro
55
73
  }
56
74
  }
57
75
 
76
+ async function deliverSessionBatchItems(input: RunnerOutboxDelivery, messages: SendMessageInput[]): Promise<void> {
77
+ for (const message of messages) {
78
+ try {
79
+ await input.http.sendMessage(message);
80
+ } catch (error) {
81
+ if (!isDeterministicValidationError(error)) throw error;
82
+ logger.warn("outbox", `dropping invalid session batch item: ${errMessage(error)}`);
83
+ }
84
+ }
85
+ }
86
+
87
+ function isDeterministicValidationError(error: unknown): boolean {
88
+ const status = typeof error === "object" && error !== null ? (error as { status?: unknown }).status : undefined;
89
+ return status === 400 || status === 413 || status === 422;
90
+ }
91
+
58
92
  export async function deliverBufferedMcpCall(input: {
59
93
  record: OutboxRecord;
60
94
  relayUrl: string;
@@ -17,6 +17,7 @@ import { startControlServer, type ControlServer, type ResolvedPermissionPrompt }
17
17
  import { ReplyObligationCache, obligationRequiresExplicitReply, responseCaptureReplyRoute } from "./reply-obligation-cache";
18
18
  import { Outbox, type OutboxRecord } from "./outbox";
19
19
  import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, countTranscriptEntries, extractFinalAssistantMessage, extractFinalAssistantMessageAfterEntry, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
20
+ import { IncrementalClaudeTranscriptTail } from "./adapters/claude-transcript-tail";
20
21
  import { getManifest } from "agent-relay-providers";
21
22
  import { profileUsesProviderHostGlobals } from "./profile-home";
22
23
  import { RELAY_MCP_TOKEN_ENV, relayMcpEndpoint, resolveSharedMcpUrl } from "./relay-mcp";
@@ -147,8 +148,10 @@ const INITIAL_PROMPT_FIRST_READY_TIMEOUT_MS = 30_000;
147
148
  const INITIAL_PROMPT_RETRY_READY_TIMEOUT_MS = 10_000;
148
149
  const MAX_INITIAL_PROMPT_ATTEMPTS = 6;
149
150
  // Reasoning tailer poll cadence (item 5). Coarse on purpose — reasoning is a
150
- // discreet progress signal, not a token stream, so ~1.2s keeps it light.
151
+ // discreet progress signal, not a token stream; each tick reads only appended
152
+ // transcript bytes.
151
153
  const REASONING_POLL_MS = 1_200;
154
+ const SESSION_MIRROR_BATCH_MAX = 100;
152
155
  interface RunnerTimelineEvent {
153
156
  status: string;
154
157
  id?: string;
@@ -160,9 +163,7 @@ interface RunnerTimelineEvent {
160
163
  }
161
164
 
162
165
  interface ReasoningTailState {
163
- timer: ReturnType<typeof setInterval>;
164
- seen: Set<string>;
165
- emittedNarrationKeys: Set<string>;
166
+ timer: ReturnType<typeof setInterval>; emittedNarrationKeys: Set<string>;
166
167
  poll(): Promise<void>;
167
168
  }
168
169
 
@@ -182,6 +183,7 @@ export class AgentRunner {
182
183
  // their own FIFO queue stops one transient trace failure from head-of-line blocking real-message
183
184
  // delivery (and vice versa); its backoff is capped low since a stale live-mirror frame is cheap.
184
185
  private readonly sessionOutbox: Outbox;
186
+ private sessionBatchSeq = 0;
185
187
  private readonly insights: RunnerInsights;
186
188
  private readonly busyReconciler: BusyReconciler;
187
189
  private readonly claudeQuotaHarvest: ClaudeQuotaHarvest;
@@ -1535,6 +1537,39 @@ export class AgentRunner {
1535
1537
  });
1536
1538
  }
1537
1539
 
1540
+ private publishSessionEventsBatch(inputs: Array<{
1541
+ from: string;
1542
+ to: string;
1543
+ body: string;
1544
+ session: MessageSessionMeta;
1545
+ replyTo?: number;
1546
+ }>): void {
1547
+ for (let i = 0; i < inputs.length; i += SESSION_MIRROR_BATCH_MAX) {
1548
+ const chunk = inputs.slice(i, i + SESSION_MIRROR_BATCH_MAX);
1549
+ if (chunk.length === 1) {
1550
+ this.publishSessionEvent(chunk[0]!);
1551
+ continue;
1552
+ }
1553
+ const messages = chunk.map((input) => {
1554
+ const stepId = input.session.stepId;
1555
+ return {
1556
+ from: input.from,
1557
+ to: input.to,
1558
+ ...(input.replyTo ? { replyTo: input.replyTo } : {}),
1559
+ kind: "session",
1560
+ body: input.body,
1561
+ ...(stepId ? { idempotencyKey: `session-step:${input.from}:${input.session.turnId ?? ""}:${stepId}` } : {}),
1562
+ payload: { session: { provider: this.options.provider, ...input.session } },
1563
+ } satisfies SendMessageInput;
1564
+ });
1565
+ this.sessionOutbox.enqueue({
1566
+ kind: "session-message-batch",
1567
+ idempotencyKey: `session-batch:${this.agentId}:${++this.sessionBatchSeq}:${messages.length}`,
1568
+ payload: { messages },
1569
+ });
1570
+ }
1571
+ }
1572
+
1538
1573
  // Map queued records to HTTP calls. Throw to retry, return to ack/delete.
1539
1574
  private async deliverOutboxEvent(record: OutboxRecord): Promise<void> {
1540
1575
  await deliverRunnerOutboxEvent({
@@ -1827,6 +1862,7 @@ export class AgentRunner {
1827
1862
  // so two identical steps in one turn — same tool, same input — both surface (#265).
1828
1863
  const seen = new Set<string>();
1829
1864
  const emittedNarrationKeys = new Set<string>();
1865
+ const transcriptTail = new IncrementalClaudeTranscriptTail();
1830
1866
  const turnIdAtStart = this.currentTurnId;
1831
1867
  // On the first poll the new prompt usually hasn't landed in the transcript yet,
1832
1868
  // so extractLatestTurnSteps still returns the PRIOR (completed) turn. Seed those
@@ -1838,24 +1874,25 @@ export class AgentRunner {
1838
1874
  let seeded = false;
1839
1875
  let pendingPoll = Promise.resolve();
1840
1876
  const pollBody = async (): Promise<void> => {
1841
- let jsonl: string;
1842
- try { jsonl = await readFile(transcriptPath, "utf8"); } catch { return; }
1843
- let steps: ReturnType<typeof extractLatestTurnSteps>;
1844
- try { steps = extractLatestTurnSteps(jsonl); } catch { return; }
1845
- const keyed = stepDedupKeys(steps).map((sig, i) => ({ sig, step: steps[i]! }));
1877
+ const result = await transcriptTail.poll(transcriptPath);
1878
+ if (!result.available) return;
1879
+ if (result.reset) { seen.clear(); emittedNarrationKeys.clear(); seeded = false; }
1880
+ if (!result.changed && (seeded || (!result.steps.length && !result.transcriptLooksComplete))) return;
1881
+ const keyed = stepDedupKeys(result.steps).map((sig, i) => ({ sig, step: result.steps[i]! }));
1846
1882
  if (!seeded) {
1847
1883
  seeded = true;
1848
- if (transcriptLooksComplete(jsonl)) {
1884
+ if (result.transcriptLooksComplete) {
1849
1885
  for (const { sig } of keyed) seen.add(sig);
1850
1886
  }
1851
1887
  }
1852
1888
  const turnId = this.currentTurnId ?? turnIdAtStart;
1853
1889
  let emitted = 0;
1890
+ const batch: Array<{ from: string; to: string; body: string; session: MessageSessionMeta }> = [];
1854
1891
  for (const { sig, step } of keyed) {
1855
1892
  if (seen.has(sig)) continue;
1856
1893
  seen.add(sig);
1857
1894
  emitted += 1;
1858
- this.publishSessionEvent({
1895
+ batch.push({
1859
1896
  from: this.agentId,
1860
1897
  to: "user",
1861
1898
  body: step.text,
@@ -1863,6 +1900,7 @@ export class AgentRunner {
1863
1900
  });
1864
1901
  if (step.type === "narration") emittedNarrationKeys.add(sig);
1865
1902
  }
1903
+ this.publishSessionEventsBatch(batch);
1866
1904
  if (emitted) this.sessionDebug(`reasoning tail emitted ${emitted} step(s) (turn ${turnId ?? "?"}, ${seen.size} total)`);
1867
1905
  };
1868
1906
  const poll = (): Promise<void> => {
@@ -1870,7 +1908,7 @@ export class AgentRunner {
1870
1908
  pendingPoll = next.catch(() => {});
1871
1909
  return next;
1872
1910
  };
1873
- this.reasoningTail = { seen, emittedNarrationKeys, poll, timer: setInterval(() => { void poll(); }, REASONING_POLL_MS) };
1911
+ this.reasoningTail = { emittedNarrationKeys, poll, timer: setInterval(() => { void poll(); }, REASONING_POLL_MS) };
1874
1912
  this.sessionLog(`reasoning tail started (turn ${turnIdAtStart ?? "?"})`);
1875
1913
  void poll();
1876
1914
  }