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
package/src/control-server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|