agent-relay-server 0.121.2 → 0.121.4

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/docs/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.121.2",
5
+ "version": "0.121.4",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.121.2",
3
+ "version": "0.121.4",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "CONTRIBUTING.md"
38
38
  ],
39
39
  "dependencies": {
40
- "agent-relay-channels-host": "0.121.2",
40
+ "agent-relay-channels-host": "0.121.4",
41
41
  "agent-relay-providers": "0.104.3",
42
42
  "agent-relay-sdk": "0.2.113",
43
43
  "ajv": "^8.20.0"
@@ -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.121.2",
4
+ "version": "0.121.4",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/bus.ts CHANGED
@@ -174,7 +174,12 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
174
174
  // frame. Flush the held report-up on THIS edge too, else `before.status` is already
175
175
  // non-busy at the next status frame → the flush never fires → the child deadlocks idle
176
176
  // with its verdict held and the coordinator waits forever.
177
- if (current?.status === "busy" && frame.payload.status !== "busy") flushIdleLineageReport(conn.agentId);
177
+ // #1037 (re-regression) gate on a genuine at-rest `idle`, NOT merely "not busy". A
178
+ // real turn-end always reports `idle`; a transient non-idle status (a stray `online`,
179
+ // a malformed frame) is NOT the end of the child's work and must not flush its held
180
+ // mid-work capture upstream. Terminal exit edges (WS-close/stale sweep) keep their
181
+ // unconditional flush for the #1040 delivery guarantee — this only tightens the LIVE edge.
182
+ if (current?.status === "busy" && frame.payload.status === "idle") flushIdleLineageReport(conn.agentId);
178
183
  }
179
184
  emitAgentStatusEvent(conn.agentId);
180
185
  // #416 — flush queued messages if the agent transitioned from unavailable to available.
@@ -220,8 +225,11 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
220
225
  // #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
221
226
  probeWorkspaceOnTurnEnd(before, after);
222
227
  // #1037 — the same busy→idle turn-end edge flushes a quiet child's deferred turn-final
223
- // report-up to its spawner (held back while the child was mid-work).
224
- if (before?.status === "busy" && after && after.status !== "busy") flushIdleLineageReport(conn.agentId);
228
+ // report-up to its spawner (held back while the child was mid-work). Gate on a genuine
229
+ // at-rest `idle`: a real turn-end reports `idle`, whereas a transient non-idle status
230
+ // (stray `online`, reconnect) is not the child going to rest and must not flush its held
231
+ // mid-work capture. Terminal exit edges keep the #1040 unconditional flush.
232
+ if (before?.status === "busy" && after && after.status === "idle") flushIdleLineageReport(conn.agentId);
225
233
  // #633 — silent clean-exit: a runner that stays alive after its provider terminally exited
226
234
  // marks the offline edge with `terminalProviderExit`; the owning service turns that into a
227
235
  // terminal event + #636 diagnosis (and clears the hold on a genuine resume).
@@ -347,6 +347,56 @@ function childIsBusy(child: AgentCard): boolean {
347
347
  return child.status === "busy";
348
348
  }
349
349
 
350
+ // #1037 (re-regression) — the report-up must reach the spawner as a CONCISE result, not the full
351
+ // turn transcript. In `chatCaptureMode: "full"` (and for rambling multi-block recaps) the captured
352
+ // turn-final body is the ENTIRE turn's narration — every "Let me read X… now Y… now Z…" segment
353
+ // joined by the canonical `\n\n` block boundary (runner extractLastAssistantTurn). Forwarding it
354
+ // verbatim floods the coordinator with a wall of mid-work text (the exact bug #1037 keeps
355
+ // re-shipping). Idle-gating alone can't help: this is a genuine turn-final at a genuine idle, so
356
+ // the payload itself must be distilled. Short bodies pass through untouched (concise finals are
357
+ // unaffected). Pure function of the body → deterministic, so re-promotion (the idle flush) yields
358
+ // the identical distilled body + idempotency key and dedups cleanly.
359
+ //
360
+ // #1056 — cut on a SEMANTIC result boundary, not a block count. The old heuristic kept the last N
361
+ // `\n\n`-separated blocks by a size budget; that is fragile to how a model CHUNKS its answer. A
362
+ // chatty Claude emitted its `## SUMMARY` result as one block (kept whole), but a chatty Codex
363
+ // paragraph-broke the heading away from its bullets, so the tail-block budget clipped the header +
364
+ // the first bullet — distillation ate the TOP of the result, delivering it mid-sentence. Fix: keep
365
+ // everything from the FIRST markdown heading onward. Workers conclude under a heading (`## SUMMARY`,
366
+ // `## FINAL REPORT`), narration is prose chatter emitted before it — so the result is the trailing
367
+ // region a heading marks the start of, and cutting at the heading drops the leading narration while
368
+ // keeping the WHOLE result, however the model split it. We cut at the FIRST heading (not the last):
369
+ // the result may itself contain sub-headings (`### Details`) or multiple `##` sections, and "last
370
+ // heading" would slice into it — the very clip #1056 is about; "first heading" never can, since it
371
+ // sits at or before the result's start. Fallback (no heading anywhere — rare; workers are told to
372
+ // emit one): there is no reliable boundary, so keep the trailing answer and trim only the leading
373
+ // narration flood by the legacy tail budget, never slicing the block we keep.
374
+ const REPORT_UP_DISTILL_THRESHOLD = 800;
375
+ const REPORT_UP_TAIL_MAX = 800;
376
+ const REPORT_UP_ELISION = "[…mid-work narration trimmed by report-up…]";
377
+ const REPORT_UP_HEADING = /^#{1,6}\s/m;
378
+
379
+ function distillLineageReportBody(body: string): string {
380
+ const trimmed = body.trim();
381
+ if (trimmed.length <= REPORT_UP_DISTILL_THRESHOLD) return body;
382
+ const heading = REPORT_UP_HEADING.exec(trimmed);
383
+ if (heading) return heading.index === 0 ? body : `${REPORT_UP_ELISION}\n\n${trimmed.slice(heading.index).trim()}`;
384
+ const blocks = trimmed.split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);
385
+ const kept: string[] = [];
386
+ let size = 0;
387
+ for (let i = blocks.length - 1; i >= 0; i--) {
388
+ const block = blocks[i]!;
389
+ if (kept.length > 0 && size + block.length > REPORT_UP_TAIL_MAX) break;
390
+ kept.unshift(block);
391
+ size += block.length + 2;
392
+ }
393
+ let tail = kept.join("\n\n");
394
+ // A single oversized trailing block (unsegmented narration) — hard-cap to its own tail.
395
+ if (tail.length > REPORT_UP_TAIL_MAX) tail = tail.slice(tail.length - REPORT_UP_TAIL_MAX).replace(/^\S*\s/, "").trimStart();
396
+ if (tail.length >= trimmed.length) return body; // nothing meaningful trimmed
397
+ return `${REPORT_UP_ELISION}\n\n${tail}`;
398
+ }
399
+
350
400
  // #1037 — a quiet child's turn-final response that arrives while the child is still busy is a
351
401
  // mid-task yield, not the end of its work. Instead of shipping it upstream (mid-work narration) or
352
402
  // dropping it (a genuine last turn whose idle status simply hasn't landed yet), we HOLD the latest
@@ -493,7 +543,9 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
493
543
  from: message.from,
494
544
  to: parent,
495
545
  kind: "chat",
496
- body: message.body,
546
+ // #1037 — distill the turn-final report-up to a concise result; verbose child-stream captures
547
+ // (an explicit subscriber that asked for the raw stream) are forwarded untouched.
548
+ body: stream === "responses" ? distillLineageReportBody(message.body) : message.body,
497
549
  replyExpected: false,
498
550
  idempotencyKey,
499
551
  payload: stream === "responses"