agent-relay-runner 0.57.0 → 0.57.2

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.57.0",
3
+ "version": "0.57.2",
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.57.0",
4
+ "version": "0.57.2",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -2,6 +2,7 @@ import type { Server, ServerWebSocket } from "bun";
2
2
  import type { Message, ReplyObligation } from "agent-relay-sdk";
3
3
  import { errMessage, isRecord } from "agent-relay-sdk";
4
4
  import type { ProviderPermissionDecisionInput, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
5
+ import { obligationRequiresExplicitReply } from "./reply-obligation-cache";
5
6
  import { logger, parseLogLevel, LOG_LEVELS } from "./logger";
6
7
  import { buildRateLimitProviderState } from "./rate-limit";
7
8
 
@@ -199,7 +200,13 @@ function replyObligationSummary(obligations: ReplyObligation[]): Record<string,
199
200
  }
200
201
 
201
202
  function replyObligationStopDecision(obligations: ReplyObligation[]): Record<string, unknown> {
202
- const summary = replyObligationSummary(obligations);
203
+ // #418 never block the turn to nag for a `user` obligation: the operator already receives
204
+ // this turn via the session mirror, so an explicit /reply double-delivers. Only agent↔agent /
205
+ // spawner obligations (#413) gate the turn here; the mirror's replyTo-linking clears the user
206
+ // one. (The /reply-obligations summary endpoint is left unfiltered — the obligation IS pending
207
+ // until the mirror lands, so observability stays accurate.)
208
+ const enforceable = obligations.filter(obligationRequiresExplicitReply);
209
+ const summary = replyObligationSummary(enforceable);
203
210
  if (summary.pending !== true) return {};
204
211
  return {
205
212
  decision: "block",
@@ -16,6 +16,17 @@ import { logger } from "./logger";
16
16
  // - A background interval keeps the snapshot warm; `markDirty()` requests an extra,
17
17
  // debounced refresh when state likely just changed (a message arrived, a turn ended).
18
18
 
19
+ // #418 — the human `user` sink is NOT delivered via an explicit relay reply: the agent's
20
+ // turn is mirrored to the dashboard chat by the session-mirror lane (always, for managed
21
+ // agents). So an explicit `/reply` to `user` double-delivers — the operator sees the mirrored
22
+ // turn AND the explicit copy. Only agent↔agent / spawner obligations (#413) need an explicit
23
+ // reply to clear and reach their recipient. This is the single home for that distinction —
24
+ // both the Claude Stop-hook nag (control-server) and the non-claude re-injection (runner)
25
+ // gate on it, so the rule must not drift between them.
26
+ export function obligationRequiresExplicitReply(obligation: ReplyObligation): boolean {
27
+ return obligation.from !== "user";
28
+ }
29
+
19
30
  type ReplyObligationFetch = () => Promise<ReplyObligation[]>;
20
31
 
21
32
  interface ReplyObligationCacheOptions {
package/src/runner.ts CHANGED
@@ -5,12 +5,13 @@ import { dirname, join } from "node:path";
5
5
  import type { AgentLifecycle, AgentProfile, ContextState, Message, MessageSessionMeta, ProviderCapabilities, SendMessageInput, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
6
6
  import { errMessage, RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
7
7
  import { contextStateFromProbeMetrics, readContextProbeState } from "agent-relay-sdk/context-probe";
8
+ import { providerMessageText } from "./adapter";
8
9
  import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
9
10
  import { messagesWithCachedAttachments } from "./attachment-cache";
10
11
  import { resolveProjectName } from "./config";
11
12
  import { ClaimTracker } from "./claim-tracker";
12
13
  import { startControlServer, type ControlServer } from "./control-server";
13
- import { ReplyObligationCache } from "./reply-obligation-cache";
14
+ import { ReplyObligationCache, obligationRequiresExplicitReply } from "./reply-obligation-cache";
14
15
  import { Outbox, type OutboxRecord } from "./outbox";
15
16
  import { extractLastAssistantTurn, extractFinalAssistantMessage, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
16
17
  import { computeContextRatio } from "./session-insights";
@@ -288,6 +289,9 @@ export class AgentRunner {
288
289
  // from the Claude transcript into chat as discreet session events.
289
290
  private reasoningTail?: { timer: ReturnType<typeof setInterval>; seen: Set<string> };
290
291
  private scratch?: SessionScratchLayout;
292
+ // #417: for non-claude providers, tracks how many times each spawner obligation has
293
+ // been re-injected so a non-responsive agent can't spin in a re-injection loop.
294
+ private readonly pendingObligationInjections = new Map<number, number>();
291
295
 
292
296
  constructor(private readonly options: RunnerOptions) {
293
297
  this.agentId = options.agentId ?? options.runnerId;
@@ -1170,6 +1174,9 @@ export class AgentRunner {
1170
1174
  this.compactionMidTurn = false;
1171
1175
  this.disarmBusyReconciler();
1172
1176
  this.stopReasoningTail();
1177
+ // #417: claude enforces obligations via the stop.sh hook; for every other provider
1178
+ // re-inject any pending spawner obligation so it surfaces the /reply command.
1179
+ if (this.options.provider !== "claude") void this.reInjectPendingObligation();
1173
1180
  }
1174
1181
  if (status === "busy") {
1175
1182
  this.claims.clearTerminalStatus();
@@ -1202,6 +1209,51 @@ export class AgentRunner {
1202
1209
  this.publishStatus();
1203
1210
  }
1204
1211
 
1212
+ // #417: when a non-claude provider finishes a turn with a pending spawner obligation
1213
+ // still unresolved, re-inject the obligation as a synthetic message so providerMessageText
1214
+ // surfaces the specific `agent-relay /reply <id>` command. Claude handles this via the
1215
+ // stop.sh hook; every other provider needs this in-process nudge.
1216
+ private async reInjectPendingObligation(): Promise<void> {
1217
+ if (!this.process) return;
1218
+ // Filter to spawner obligations only — user obligations go through the existing codex
1219
+ // prompt-capture path (publishProviderSessionEvent) and the session mirror (#418), never
1220
+ // an explicit /reply. Shared predicate so this rule stays in lockstep with the Claude
1221
+ // Stop-hook nag exemption (control-server.replyObligationStopDecision).
1222
+ const obligations = this.obligationCache.get().filter(obligationRequiresExplicitReply);
1223
+ if (!obligations.length) return;
1224
+ const MAX_REINJECTIONS = 3;
1225
+ for (const obligation of obligations) {
1226
+ const count = this.pendingObligationInjections.get(obligation.messageId) ?? 0;
1227
+ if (count >= MAX_REINJECTIONS) continue;
1228
+ this.pendingObligationInjections.set(obligation.messageId, count + 1);
1229
+ this.sessionLog(`re-injecting pending reply obligation #${obligation.messageId} from ${obligation.from} (attempt ${count + 1}/${MAX_REINJECTIONS})`);
1230
+ const replayMessage = {
1231
+ id: obligation.messageId,
1232
+ from: obligation.from,
1233
+ to: this.agentId,
1234
+ kind: obligation.kind,
1235
+ ...(obligation.subject ? { subject: obligation.subject } : {}),
1236
+ body: obligation.bodyPreview,
1237
+ payload: {},
1238
+ readBy: [] as string[],
1239
+ createdAt: obligation.createdAt,
1240
+ };
1241
+ // Suppress the userMessage echo that providers (e.g. Codex) reflect back when
1242
+ // a turn is started with relay-formatted content — the echo body is exactly what
1243
+ // providerMessageText produces, which starts with "[relay message #" and is caught
1244
+ // by the RELAY_INJECTION_MARKERS prefix check, but record it explicitly as
1245
+ // defense-in-depth for adapters that might echo only the unformatted body text.
1246
+ this.recordInjectedPrompt(providerMessageText([replayMessage]));
1247
+ await this.options.adapter.deliver(this.process, [replayMessage]);
1248
+ // Kick off a cache refresh so a cleared obligation (agent replied) is detected
1249
+ // before the next idle fires — without this, the stale snapshot re-injects up to
1250
+ // MAX_REINJECTIONS times even after the agent has already /reply-ed.
1251
+ this.obligationCache.markDirty();
1252
+ // One obligation at a time — let the provider reply before nudging again.
1253
+ break;
1254
+ }
1255
+ }
1256
+
1205
1257
  // Session-mirror lane: capture the assistant turn from the Claude transcript and
1206
1258
  // post it as a "session" message so it shows in the dashboard chat with zero
1207
1259
  // agent tokens. Capture is UNCONDITIONAL — it no longer depends on a triggering