agent-relay-runner 0.73.1 → 0.75.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 +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapters/codex-agent-message-capture.ts +56 -0
- package/src/adapters/codex-client.ts +9 -0
- package/src/adapters/codex.ts +30 -31
- package/src/quota.ts +283 -0
- package/src/runner-core.ts +10 -8
- package/src/runner-helpers.ts +22 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.75.0",
|
|
4
4
|
"description": "Unified provider lifecycle runner for Agent Relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"directory": "runner"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"agent-relay-sdk": "0.2.
|
|
23
|
+
"agent-relay-sdk": "0.2.50"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "latest",
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
type EmitAgentMessage = (body: string) => void;
|
|
2
|
+
|
|
3
|
+
export class CodexAgentMessageCapture {
|
|
4
|
+
private segments: string[][] = [];
|
|
5
|
+
private currentSegment: string[] = [];
|
|
6
|
+
private readonly buffers = new Map<string, string>();
|
|
7
|
+
private readonly flushedLengths = new Map<string, number>();
|
|
8
|
+
|
|
9
|
+
reset(): void {
|
|
10
|
+
this.segments = [];
|
|
11
|
+
this.currentSegment = [];
|
|
12
|
+
this.buffers.clear();
|
|
13
|
+
this.flushedLengths.clear();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
appendDelta(itemId: string | undefined, delta: string): void {
|
|
17
|
+
if (!itemId || !delta) return;
|
|
18
|
+
this.buffers.set(itemId, `${this.buffers.get(itemId) ?? ""}${delta}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
completeItem(itemId: string | undefined, text: string | undefined, emit: EmitAgentMessage): void {
|
|
22
|
+
this.flushText(itemId, text ?? (itemId ? this.buffers.get(itemId) : undefined), emit);
|
|
23
|
+
if (!itemId) return;
|
|
24
|
+
this.buffers.delete(itemId);
|
|
25
|
+
this.flushedLengths.delete(itemId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
closeSegment(emit: EmitAgentMessage): void {
|
|
29
|
+
this.flushPending(emit);
|
|
30
|
+
if (!this.currentSegment.length) return;
|
|
31
|
+
this.segments.push(this.currentSegment);
|
|
32
|
+
this.currentSegment = [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
finish(emit: EmitAgentMessage): string | undefined {
|
|
36
|
+
this.closeSegment(emit);
|
|
37
|
+
const finalSegment = this.segments.at(-1);
|
|
38
|
+
this.segments = [];
|
|
39
|
+
return finalSegment?.length ? finalSegment.join("\n\n").trim() : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private flushPending(emit: EmitAgentMessage): void {
|
|
43
|
+
for (const [itemId, text] of [...this.buffers.entries()]) this.flushText(itemId, text, emit);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private flushText(itemId: string | undefined, text: string | undefined, emit: EmitAgentMessage): void {
|
|
47
|
+
if (!text) return;
|
|
48
|
+
const offset = itemId ? (this.flushedLengths.get(itemId) ?? 0) : 0;
|
|
49
|
+
const chunk = text.slice(offset);
|
|
50
|
+
if (itemId) this.flushedLengths.set(itemId, text.length);
|
|
51
|
+
const body = chunk.trim();
|
|
52
|
+
if (!body) return;
|
|
53
|
+
this.currentSegment.push(body);
|
|
54
|
+
emit(body);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -74,6 +74,11 @@ interface ThreadLoadedListResponse {
|
|
|
74
74
|
nextCursor: string | null;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
interface AccountRateLimitsResponse {
|
|
78
|
+
rateLimits?: unknown;
|
|
79
|
+
rate_limits?: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
77
82
|
export const CODEX_APP_CLIENT_EVENT_CAP = 5_000;
|
|
78
83
|
|
|
79
84
|
export class CodexAppClient {
|
|
@@ -193,6 +198,10 @@ export class CodexAppClient {
|
|
|
193
198
|
return this.request<Record<string, never>>("turn/interrupt", { threadId, turnId });
|
|
194
199
|
}
|
|
195
200
|
|
|
201
|
+
async accountRateLimitsRead(): Promise<AccountRateLimitsResponse> {
|
|
202
|
+
return this.request<AccountRateLimitsResponse>("account/rateLimits/read");
|
|
203
|
+
}
|
|
204
|
+
|
|
196
205
|
respondToServerRequest(id: JsonRpcId, result: unknown): void {
|
|
197
206
|
if (!this.connected) {
|
|
198
207
|
throw new Error("websocket not connected");
|
package/src/adapters/codex.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { tomlString } from "../relay-mcp";
|
|
|
10
10
|
import { assembleLaunch, bundledCodexSkillDirs, bundledSkillConfigArgs, materializeLaunchAssembly } from "../launch-assembly";
|
|
11
11
|
import { logger } from "../logger";
|
|
12
12
|
import type { SessionEvent } from "../session-insights";
|
|
13
|
+
import { CodexAgentMessageCapture } from "./codex-agent-message-capture";
|
|
13
14
|
|
|
14
15
|
/** Relay context prepended to a Codex agent's first turn: the standard relay
|
|
15
16
|
* blurb plus, when running in an isolated workspace, the deps caveat (#159). */
|
|
@@ -49,12 +50,9 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
49
50
|
// Active turn id for the main thread, captured from turn/started so an interrupt
|
|
50
51
|
// can target the in-flight turn. Cleared on turn/completed.
|
|
51
52
|
private activeTurnId?: string;
|
|
52
|
-
|
|
53
|
-
// flushed as one session response on turn/completed (mirrors Claude's chatCaptureMode).
|
|
54
|
-
private turnMessages: string[] = [];
|
|
53
|
+
private readonly agentMessageCapture = new CodexAgentMessageCapture();
|
|
55
54
|
private readonly itemTextBuffers = new Map<string, string>();
|
|
56
55
|
private readonly itemTextBufferTypes = new Map<string, string>();
|
|
57
|
-
private captureMode: "final" | "full" = "final";
|
|
58
56
|
// #183/#184: the normalized session-event log for the current process lifetime, fed
|
|
59
57
|
// from the same completed-item stream that drives the chat mirror. The runner slices
|
|
60
58
|
// this per-segment (since the last compact/clear/restart) via its own cursor, so we
|
|
@@ -81,7 +79,7 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
81
79
|
this.subagentThreads.clear();
|
|
82
80
|
this.pendingApprovals.clear();
|
|
83
81
|
this.activeTurnId = undefined;
|
|
84
|
-
this.
|
|
82
|
+
this.agentMessageCapture.reset();
|
|
85
83
|
this.itemTextBuffers.clear();
|
|
86
84
|
this.itemTextBufferTypes.clear();
|
|
87
85
|
}
|
|
@@ -133,7 +131,6 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
133
131
|
|
|
134
132
|
async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
|
|
135
133
|
this.resetProcessState();
|
|
136
|
-
this.captureMode = (config.providerConfig as ProviderConfig).chatCaptureMode ?? "final";
|
|
137
134
|
const args = this.buildSpawnArgs(config, config.providerConfig as ProviderConfig);
|
|
138
135
|
const appServer = Bun.spawn([args.command, ...args.args], {
|
|
139
136
|
cwd: args.cwd,
|
|
@@ -459,8 +456,9 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
459
456
|
} else {
|
|
460
457
|
const turn = isRecord(params?.turn) ? params.turn : undefined;
|
|
461
458
|
this.activeTurnId = stringValue(turn?.id);
|
|
462
|
-
this.
|
|
459
|
+
this.agentMessageCapture.reset();
|
|
463
460
|
this.itemTextBuffers.clear();
|
|
461
|
+
this.itemTextBufferTypes.clear();
|
|
464
462
|
this.statusCb({ status: "busy", reason: "provider-turn", id: this.activeTurnId });
|
|
465
463
|
}
|
|
466
464
|
}
|
|
@@ -492,29 +490,21 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
492
490
|
}
|
|
493
491
|
}
|
|
494
492
|
|
|
495
|
-
// Turn one completed Codex thread item into a session-mirror event. agentMessage
|
|
496
|
-
//
|
|
497
|
-
// (user prompt echo, reasoning, tool steps) is surfaced
|
|
493
|
+
// Turn one completed Codex thread item into a session-mirror event. agentMessage text is
|
|
494
|
+
// mirrored as narration as it lands; only the final contiguous assistant segment is repeated
|
|
495
|
+
// as the turn-final response. The rest (user prompt echo, reasoning, tool steps) is surfaced
|
|
496
|
+
// as it lands.
|
|
498
497
|
private handleCodexItem(item: Record<string, unknown> | undefined): void {
|
|
499
498
|
if (!item) return;
|
|
500
499
|
const type = stringValue(item.type);
|
|
501
500
|
const turnId = this.activeTurnId;
|
|
502
501
|
const itemId = codexItemId(item);
|
|
502
|
+
if (type !== "agentMessage") this.agentMessageCapture.closeSegment((body) => this.emitAgentNarration(body));
|
|
503
503
|
// A completed non-reasoning item ends the reasoning segment that preceded it — flush the
|
|
504
504
|
// buffered reasoning first so it lands in transcript order, ahead of this tool/message.
|
|
505
505
|
if (type !== "reasoning") this.flushBufferedReasoning();
|
|
506
506
|
if (type === "agentMessage") {
|
|
507
|
-
|
|
508
|
-
if (text) {
|
|
509
|
-
this.turnMessages.push(text);
|
|
510
|
-
this.recordInsightEvent({ type: "turn" }); // a substantive assistant turn
|
|
511
|
-
// Stream the assistant text into the trace as narration (Claude parity). The closing
|
|
512
|
-
// response bubble at turn end repeats the final block; the dashboard suppresses the
|
|
513
|
-
// matching narration so it isn't shown twice.
|
|
514
|
-
this.sessionEventCb({ type: "narration", origin: "provider", body: text, ...(turnId ? { turnId } : {}) });
|
|
515
|
-
}
|
|
516
|
-
if (itemId) this.itemTextBuffers.delete(itemId);
|
|
517
|
-
if (itemId) this.itemTextBufferTypes.delete(itemId);
|
|
507
|
+
this.agentMessageCapture.completeItem(itemId, stringValue(item.text), (body) => this.emitAgentNarration(body));
|
|
518
508
|
return;
|
|
519
509
|
}
|
|
520
510
|
if (type === "userMessage") {
|
|
@@ -549,6 +539,14 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
549
539
|
if (itemId) this.itemTextBufferTypes.delete(itemId);
|
|
550
540
|
}
|
|
551
541
|
|
|
542
|
+
private emitAgentNarration(body: string): void {
|
|
543
|
+
this.recordInsightEvent({ type: "turn" }); // a substantive assistant turn
|
|
544
|
+
// Stream the assistant text into the trace as narration (Claude parity). The closing
|
|
545
|
+
// response bubble at turn end repeats the final block; the dashboard suppresses the
|
|
546
|
+
// matching narration so it isn't shown twice.
|
|
547
|
+
this.sessionEventCb({ type: "narration", origin: "provider", body, ...(this.activeTurnId ? { turnId: this.activeTurnId } : {}) });
|
|
548
|
+
}
|
|
549
|
+
|
|
552
550
|
// Emit any buffered reasoning as discrete trace blocks (appended, in transcript order). Codex
|
|
553
551
|
// streams reasoning as deltas with no reliable completion event, so we flush coarsely at item
|
|
554
552
|
// and turn boundaries — the signal the Claude reasoning tail uses — never a codex-only timer.
|
|
@@ -588,6 +586,7 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
588
586
|
const turnId = this.activeTurnId;
|
|
589
587
|
|
|
590
588
|
if (method.includes("/started") || method.includes(".started")) {
|
|
589
|
+
if (type !== "agentMessage") this.agentMessageCapture.closeSegment((body) => this.emitAgentNarration(body));
|
|
591
590
|
// A new item starting ends the prior reasoning segment — flush it ahead of this step.
|
|
592
591
|
this.flushBufferedReasoning();
|
|
593
592
|
const tool = codexToolSummary(type, item ?? params ?? {});
|
|
@@ -596,7 +595,14 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
596
595
|
return;
|
|
597
596
|
}
|
|
598
597
|
|
|
599
|
-
if (type === "agentMessage"
|
|
598
|
+
if (type === "agentMessage") {
|
|
599
|
+
const delta = codexDeltaText(params);
|
|
600
|
+
if (delta) this.agentMessageCapture.appendDelta(itemId, delta);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (type === "reasoning" || type === "plan") {
|
|
605
|
+
this.agentMessageCapture.closeSegment((body) => this.emitAgentNarration(body));
|
|
600
606
|
const delta = codexDeltaText(params);
|
|
601
607
|
if (delta && itemId) {
|
|
602
608
|
this.itemTextBuffers.set(itemId, `${this.itemTextBuffers.get(itemId) ?? ""}${delta}`);
|
|
@@ -611,15 +617,7 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
611
617
|
}
|
|
612
618
|
|
|
613
619
|
private flushTurnResponse(): void {
|
|
614
|
-
const
|
|
615
|
-
.filter(([itemId]) => this.itemTextBufferTypes.get(itemId) === "agentMessage")
|
|
616
|
-
.map(([, text]) => text.trim())
|
|
617
|
-
.filter(Boolean);
|
|
618
|
-
const messages = [...this.turnMessages, ...pendingAgentMessages];
|
|
619
|
-
if (!messages.length) return;
|
|
620
|
-
const joined = this.captureMode === "full" ? messages.join("\n\n") : messages[messages.length - 1]!;
|
|
621
|
-
this.turnMessages = [];
|
|
622
|
-
const text = joined.trim();
|
|
620
|
+
const text = this.agentMessageCapture.finish((body) => this.emitAgentNarration(body));
|
|
623
621
|
if (text) this.sessionEventCb({ type: "response", origin: "provider", body: text, final: true, ...(this.activeTurnId ? { turnId: this.activeTurnId } : {}) });
|
|
624
622
|
}
|
|
625
623
|
|
|
@@ -631,6 +629,7 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
631
629
|
this.pendingApprovals.clear();
|
|
632
630
|
this.itemTextBuffers.clear();
|
|
633
631
|
this.itemTextBufferTypes.clear();
|
|
632
|
+
this.agentMessageCapture.reset();
|
|
634
633
|
this.statusCb({ status: "idle", reason: "provider-turn", id: turnId });
|
|
635
634
|
}
|
|
636
635
|
|
package/src/quota.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ContextProbeMetrics, QuotaState, QuotaWindowName } from "agent-relay-sdk";
|
|
5
|
+
import { errMessage, isRecord, quotaStateFromProbeMetrics, stringValue } from "agent-relay-sdk";
|
|
6
|
+
import type { ManagedProcess } from "./adapter";
|
|
7
|
+
|
|
8
|
+
const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
9
|
+
const CLAUDE_OAUTH_BETA = "oauth-2025-04-20";
|
|
10
|
+
const QUOTA_POLL_INTERVAL_MS = 15 * 60 * 1000;
|
|
11
|
+
const QUOTA_FAILURE_LOG_INTERVAL_MS = 5 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
type CodexQuotaClient = {
|
|
14
|
+
isConnected?: () => boolean;
|
|
15
|
+
accountRateLimitsRead?: () => Promise<unknown>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export class RunnerQuotaPoller {
|
|
19
|
+
private quotaState?: QuotaState;
|
|
20
|
+
private timer?: Timer;
|
|
21
|
+
private inFlight = false;
|
|
22
|
+
private active = false;
|
|
23
|
+
private lastLog?: { key: string; at: number };
|
|
24
|
+
|
|
25
|
+
constructor(private readonly options: {
|
|
26
|
+
provider: string;
|
|
27
|
+
agentId: string;
|
|
28
|
+
getProcess: () => ManagedProcess | undefined;
|
|
29
|
+
onUpdate: () => void;
|
|
30
|
+
log: (message: string) => void;
|
|
31
|
+
}) {}
|
|
32
|
+
|
|
33
|
+
get current(): QuotaState | undefined {
|
|
34
|
+
return this.quotaState;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
start(): void {
|
|
38
|
+
this.stop();
|
|
39
|
+
if (!quotaProviderSupported(this.options.provider)) return;
|
|
40
|
+
this.active = true;
|
|
41
|
+
void this.refresh();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
stop(): void {
|
|
45
|
+
this.active = false;
|
|
46
|
+
if (this.timer) clearTimeout(this.timer);
|
|
47
|
+
this.timer = undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
clear(): void {
|
|
51
|
+
this.quotaState = undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private schedule(delayMs = QUOTA_POLL_INTERVAL_MS): void {
|
|
55
|
+
if (this.timer) clearTimeout(this.timer);
|
|
56
|
+
this.timer = undefined;
|
|
57
|
+
if (!this.active || !quotaProviderSupported(this.options.provider)) return;
|
|
58
|
+
this.timer = setTimeout(() => {
|
|
59
|
+
this.timer = undefined;
|
|
60
|
+
void this.refresh();
|
|
61
|
+
}, Math.max(1_000, delayMs));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async refresh(): Promise<void> {
|
|
65
|
+
if (this.inFlight) return;
|
|
66
|
+
this.inFlight = true;
|
|
67
|
+
try {
|
|
68
|
+
const quota = await collectRunnerQuotaState({
|
|
69
|
+
provider: this.options.provider,
|
|
70
|
+
agentId: this.options.agentId,
|
|
71
|
+
process: this.options.getProcess(),
|
|
72
|
+
});
|
|
73
|
+
if (quota && this.active) {
|
|
74
|
+
this.quotaState = quota;
|
|
75
|
+
this.options.onUpdate();
|
|
76
|
+
}
|
|
77
|
+
this.schedule();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const retryAfterMs = quotaRetryAfterMs(error);
|
|
80
|
+
if (this.active) this.logFailure(error, retryAfterMs);
|
|
81
|
+
this.schedule(retryAfterMs ?? QUOTA_POLL_INTERVAL_MS);
|
|
82
|
+
} finally {
|
|
83
|
+
this.inFlight = false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private logFailure(error: unknown, retryAfterMs: number | undefined): void {
|
|
88
|
+
const key = retryAfterMs !== undefined ? `retry-after:${retryAfterMs}` : errMessage(error);
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
if (this.lastLog?.key === key && now - this.lastLog.at < QUOTA_FAILURE_LOG_INTERVAL_MS) return;
|
|
91
|
+
this.lastLog = { key, at: now };
|
|
92
|
+
const suffix = retryAfterMs !== undefined ? `; retrying in ${Math.round(retryAfterMs / 1000)}s` : "";
|
|
93
|
+
this.options.log(`quota refresh failed${suffix}: ${errMessage(error)}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class QuotaRetryAfterError extends Error {
|
|
98
|
+
constructor(readonly retryAfterMs: number, message = "quota source requested retry backoff") {
|
|
99
|
+
super(message);
|
|
100
|
+
this.name = "QuotaRetryAfterError";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function quotaRetryAfterMs(error: unknown): number | undefined {
|
|
105
|
+
return error instanceof QuotaRetryAfterError ? error.retryAfterMs : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function collectRunnerQuotaState(input: {
|
|
109
|
+
provider: string;
|
|
110
|
+
agentId: string;
|
|
111
|
+
process?: ManagedProcess;
|
|
112
|
+
}): Promise<QuotaState | undefined> {
|
|
113
|
+
if (input.provider === "claude") return collectClaudeQuotaState({ agentId: input.agentId });
|
|
114
|
+
if (input.provider !== "codex") return undefined;
|
|
115
|
+
const client = input.process?.meta?.client as CodexQuotaClient | undefined;
|
|
116
|
+
if (!client?.isConnected?.() || typeof client.accountRateLimitsRead !== "function") return undefined;
|
|
117
|
+
return collectCodexQuotaState({
|
|
118
|
+
agentId: codexProcessAgentId(input.process, input.agentId),
|
|
119
|
+
rateLimitsRead: () => client.accountRateLimitsRead!(),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function collectClaudeQuotaState(options: {
|
|
124
|
+
agentId: string;
|
|
125
|
+
credentialsPath?: string;
|
|
126
|
+
fetchImpl?: typeof fetch;
|
|
127
|
+
now?: number;
|
|
128
|
+
}): Promise<QuotaState | undefined> {
|
|
129
|
+
const accessToken = await readClaudeOAuthAccessToken(options.credentialsPath ?? defaultClaudeCredentialsPath());
|
|
130
|
+
if (!accessToken) return undefined;
|
|
131
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
132
|
+
const response = await fetchImpl(CLAUDE_USAGE_URL, {
|
|
133
|
+
method: "GET",
|
|
134
|
+
headers: {
|
|
135
|
+
authorization: `Bearer ${accessToken}`,
|
|
136
|
+
"anthropic-beta": CLAUDE_OAUTH_BETA,
|
|
137
|
+
},
|
|
138
|
+
signal: AbortSignal.timeout(10_000),
|
|
139
|
+
});
|
|
140
|
+
if (response.status === 429) {
|
|
141
|
+
throw new QuotaRetryAfterError(retryAfterHeaderMs(response.headers.get("retry-after")) ?? 60_000, "Claude quota source returned 429");
|
|
142
|
+
}
|
|
143
|
+
if (!response.ok) return undefined;
|
|
144
|
+
return claudeUsageQuotaState(await response.json(), options.agentId, options.now ?? Date.now());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function claudeUsageQuotaState(payload: unknown, agentId: string, now = Date.now()): QuotaState | undefined {
|
|
148
|
+
return quotaStateFromProbeMetrics({
|
|
149
|
+
agentId,
|
|
150
|
+
contextPercent: 0,
|
|
151
|
+
quotaWindows: [
|
|
152
|
+
quotaWindowFromAnthropicUsage("five_hour", payload),
|
|
153
|
+
quotaWindowFromAnthropicUsage("seven_day", payload),
|
|
154
|
+
].filter((window): window is NonNullable<ContextProbeMetrics["quotaWindows"]>[number] => Boolean(window)),
|
|
155
|
+
source: "api",
|
|
156
|
+
confidence: "reported",
|
|
157
|
+
timestamp: now,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function collectCodexQuotaState(input: {
|
|
162
|
+
agentId: string;
|
|
163
|
+
rateLimitsRead: () => Promise<unknown>;
|
|
164
|
+
now?: number;
|
|
165
|
+
}): Promise<QuotaState | undefined> {
|
|
166
|
+
try {
|
|
167
|
+
return codexRateLimitsQuotaState(await input.rateLimitsRead(), input.agentId, input.now ?? Date.now());
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const retryAfter = codexRetryAfterMs(error);
|
|
170
|
+
if (retryAfter !== undefined) throw new QuotaRetryAfterError(retryAfter, "Codex quota source returned 429");
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function codexRateLimitsQuotaState(payload: unknown, agentId: string, now = Date.now()): QuotaState | undefined {
|
|
176
|
+
const rateLimits = isRecord(payload) && isRecord(payload.rateLimits) ? payload.rateLimits
|
|
177
|
+
: isRecord(payload) && isRecord(payload.rate_limits) ? payload.rate_limits
|
|
178
|
+
: payload;
|
|
179
|
+
return quotaStateFromProbeMetrics({
|
|
180
|
+
agentId,
|
|
181
|
+
contextPercent: 0,
|
|
182
|
+
quotaWindows: [
|
|
183
|
+
quotaWindowFromCodexRateLimit("primary", rateLimits),
|
|
184
|
+
quotaWindowFromCodexRateLimit("secondary", rateLimits),
|
|
185
|
+
].filter((window): window is NonNullable<ContextProbeMetrics["quotaWindows"]>[number] => Boolean(window)),
|
|
186
|
+
source: "api",
|
|
187
|
+
confidence: "reported",
|
|
188
|
+
timestamp: now,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function defaultClaudeCredentialsPath(): string {
|
|
193
|
+
return join(homedir(), ".claude", ".credentials.json");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function quotaProviderSupported(provider: string): boolean {
|
|
197
|
+
return provider === "claude" || provider === "codex";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function codexProcessAgentId(process: ManagedProcess | undefined, fallback: string): string {
|
|
201
|
+
const config = isRecord(process?.meta?.config) ? process.meta.config : undefined;
|
|
202
|
+
return stringValue(config?.agentId) ?? fallback;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function readClaudeOAuthAccessToken(path: string): Promise<string | undefined> {
|
|
206
|
+
try {
|
|
207
|
+
const parsed = JSON.parse(await readFile(path, "utf8")) as unknown;
|
|
208
|
+
const oauth = isRecord(parsed) && isRecord(parsed.claudeAiOauth) ? parsed.claudeAiOauth : undefined;
|
|
209
|
+
const token = oauth?.accessToken;
|
|
210
|
+
return typeof token === "string" && token.trim() ? token : undefined;
|
|
211
|
+
} catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function quotaWindowFromAnthropicUsage(name: QuotaWindowName, payload: unknown): NonNullable<ContextProbeMetrics["quotaWindows"]>[number] | undefined {
|
|
217
|
+
if (!isRecord(payload)) return undefined;
|
|
218
|
+
const raw = isRecord(payload[name]) ? payload[name] : undefined;
|
|
219
|
+
const utilization = normalizePercentLike(raw?.utilization);
|
|
220
|
+
if (utilization === undefined) return undefined;
|
|
221
|
+
return {
|
|
222
|
+
name,
|
|
223
|
+
utilization,
|
|
224
|
+
...(resetValueMs(raw?.resets_at) !== undefined ? { resetsAt: resetValueMs(raw?.resets_at)! } : {}),
|
|
225
|
+
unit: "%",
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function quotaWindowFromCodexRateLimit(name: QuotaWindowName, payload: unknown): NonNullable<ContextProbeMetrics["quotaWindows"]>[number] | undefined {
|
|
230
|
+
if (!isRecord(payload)) return undefined;
|
|
231
|
+
const raw = isRecord(payload[name]) ? payload[name] : undefined;
|
|
232
|
+
const utilization = normalizePercentLike(raw?.usedPercent ?? raw?.used_percent ?? raw?.utilization);
|
|
233
|
+
if (utilization === undefined) return undefined;
|
|
234
|
+
return {
|
|
235
|
+
name,
|
|
236
|
+
utilization,
|
|
237
|
+
...(resetValueMs(raw?.resetsAt ?? raw?.resets_at) !== undefined ? { resetsAt: resetValueMs(raw?.resetsAt ?? raw?.resets_at)! } : {}),
|
|
238
|
+
unit: "%",
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function normalizePercentLike(value: unknown): number | undefined {
|
|
243
|
+
const number = numberValue(value);
|
|
244
|
+
if (number === undefined) return undefined;
|
|
245
|
+
const fraction = number > 1 ? number / 100 : number;
|
|
246
|
+
return Math.max(0, Math.min(1, fraction));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function resetValueMs(value: unknown): number | undefined {
|
|
250
|
+
const number = numberValue(value);
|
|
251
|
+
if (number !== undefined) return number < 10_000_000_000 ? number * 1000 : number;
|
|
252
|
+
if (typeof value !== "string" || !value.trim()) return undefined;
|
|
253
|
+
const parsed = Date.parse(value);
|
|
254
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function retryAfterHeaderMs(value: string | null): number | undefined {
|
|
258
|
+
if (!value) return undefined;
|
|
259
|
+
const seconds = Number(value);
|
|
260
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
261
|
+
const date = Date.parse(value);
|
|
262
|
+
if (!Number.isFinite(date)) return undefined;
|
|
263
|
+
return Math.max(0, date - Date.now());
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function codexRetryAfterMs(error: unknown): number | undefined {
|
|
267
|
+
const message = errMessage(error);
|
|
268
|
+
if (!/\b429\b/.test(message)) return undefined;
|
|
269
|
+
return retryAfterHeaderMs(retryAfterFromText(message)) ?? 60_000;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function retryAfterFromText(message: string): string | null {
|
|
273
|
+
return /retry-after[:=]\s*([^,;\s]+)/i.exec(message)?.[1] ?? null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function numberValue(value: unknown): number | undefined {
|
|
277
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
278
|
+
if (typeof value === "string" && value.trim()) {
|
|
279
|
+
const parsed = Number(value);
|
|
280
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
package/src/runner-core.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import type { AgentLifecycle, AgentProfile, ContextState, Message, MessageSessionMeta, SendMessageInput, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
|
|
6
6
|
import { errMessage, RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
|
|
7
|
-
import { contextStateFromProbeMetrics, readContextProbeState } from "agent-relay-sdk/context-probe";
|
|
7
|
+
import { contextStateFromProbeMetrics, quotaStateFromProbeMetrics, readContextProbeState } from "agent-relay-sdk/context-probe";
|
|
8
8
|
import { providerAttachmentText, providerMessageText } from "./adapter";
|
|
9
9
|
import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
|
|
10
10
|
import { messagesWithCachedAttachments } from "./attachment-cache";
|
|
@@ -24,6 +24,7 @@ import { RunnerInsights } from "./runner-insights";
|
|
|
24
24
|
import { BusyReconciler } from "./busy-reconciler";
|
|
25
25
|
import { publishCapturedResponse } from "./response-capture-report";
|
|
26
26
|
import { runnerLaunchInjectionSessionEvents, type RunnerRelayInjectionEvent } from "./relay-injection-events";
|
|
27
|
+
import { RunnerQuotaPoller } from "./quota";
|
|
27
28
|
import { capsFromEnv, contextStateDirFromEnv, logLevelFromEnv, mcpProxyEnabledFromEnv, orchestratorBaseDirFromEnv, orchestratorUrlFromEnv, registrationTimeoutMsFromEnv, runnerInfoFileFromEnv, runnerLogFileFromEnv, runnerOutboxDirWithInfoFallback, sessionDebugEnabled, tagsFromEnv, workspaceModeFromEnv } from "./config";
|
|
28
29
|
import {
|
|
29
30
|
appliedAgentProfileMetadata,
|
|
@@ -204,6 +205,7 @@ export class AgentRunner {
|
|
|
204
205
|
private readonly sessionOutbox: Outbox;
|
|
205
206
|
private readonly insights: RunnerInsights;
|
|
206
207
|
private readonly busyReconciler: BusyReconciler;
|
|
208
|
+
private readonly quotaPoller: RunnerQuotaPoller;
|
|
207
209
|
private currentToken?: string;
|
|
208
210
|
private currentTokenJti?: string;
|
|
209
211
|
private currentTokenProfileId?: string;
|
|
@@ -351,6 +353,7 @@ export class AgentRunner {
|
|
|
351
353
|
clearProviderTurn: (reason) => this.forceClearProviderTurn(reason),
|
|
352
354
|
sessionDebug: (message) => this.sessionDebug(message),
|
|
353
355
|
});
|
|
356
|
+
this.quotaPoller = new RunnerQuotaPoller({ provider: options.provider, agentId: this.agentId, getProcess: () => this.process, onUpdate: () => this.publishStatus(), log: (message) => this.logRunnerDiagnostic(message) });
|
|
354
357
|
this.bus = new RelayBusClient({
|
|
355
358
|
url: relayBusUrl(options.relayUrl),
|
|
356
359
|
role: "provider",
|
|
@@ -461,7 +464,7 @@ export class AgentRunner {
|
|
|
461
464
|
void this.sweepStaleScratch();
|
|
462
465
|
this.process = await this.spawnProvider();
|
|
463
466
|
this.writeRunnerInfoFile();
|
|
464
|
-
this.processStartedAt = Date.now();
|
|
467
|
+
this.processStartedAt = Date.now(); this.quotaPoller.start();
|
|
465
468
|
this.publishStatus();
|
|
466
469
|
await this.deliverInitialPrompt();
|
|
467
470
|
await this.bootstrapUnreadMessages();
|
|
@@ -493,7 +496,7 @@ export class AgentRunner {
|
|
|
493
496
|
if (this.httpLivenessTimer) clearInterval(this.httpLivenessTimer);
|
|
494
497
|
this.httpLivenessTimer = undefined;
|
|
495
498
|
if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
|
|
496
|
-
this.tokenRenewTimer = undefined;
|
|
499
|
+
this.tokenRenewTimer = undefined; this.quotaPoller.stop();
|
|
497
500
|
this.busyReconciler.disarm();
|
|
498
501
|
this.stopReasoningTail();
|
|
499
502
|
this.obligationCache.stop();
|
|
@@ -934,6 +937,7 @@ export class AgentRunner {
|
|
|
934
937
|
if (this.stopped) return;
|
|
935
938
|
this.process = await this.spawnProvider();
|
|
936
939
|
this.processStartedAt = Date.now();
|
|
940
|
+
this.quotaPoller.clear(); this.quotaPoller.start();
|
|
937
941
|
} finally {
|
|
938
942
|
this.restartInProgress = false;
|
|
939
943
|
}
|
|
@@ -2110,21 +2114,19 @@ export class AgentRunner {
|
|
|
2110
2114
|
const probeMetrics = this.latestProbeMetrics();
|
|
2111
2115
|
const probeContext = probeMetrics ? contextStateFromProbeMetrics(probeMetrics) : undefined;
|
|
2112
2116
|
const context = processContext ?? probeContext;
|
|
2117
|
+
const quota = this.quotaPoller.current ?? (probeMetrics ? quotaStateFromProbeMetrics(probeMetrics) : undefined);
|
|
2113
2118
|
const terminalSession = this.providerTerminalSession();
|
|
2114
2119
|
const terminalSocket = this.providerTerminalSocket();
|
|
2115
2120
|
const probeModel: ProbeModelInfo | undefined = probeMetrics?.model || probeMetrics?.effort
|
|
2116
2121
|
? { model: probeMetrics.model, effort: probeMetrics.effort }
|
|
2117
2122
|
: undefined;
|
|
2118
2123
|
const meta: Record<string, unknown> = {
|
|
2119
|
-
providerCapabilities: runtimeProviderCapabilities(
|
|
2120
|
-
this.options,
|
|
2121
|
-
context,
|
|
2122
|
-
probeModel,
|
|
2123
|
-
),
|
|
2124
|
+
providerCapabilities: runtimeProviderCapabilities(this.options, context, probeModel, quota),
|
|
2124
2125
|
...(terminalSession ? { tmuxSession: terminalSession } : {}),
|
|
2125
2126
|
...(terminalSocket ? { tmuxSocket: terminalSocket } : {}),
|
|
2126
2127
|
};
|
|
2127
2128
|
if (context) meta.context = context;
|
|
2129
|
+
if (quota) meta.quota = quota;
|
|
2128
2130
|
return meta;
|
|
2129
2131
|
}
|
|
2130
2132
|
|
package/src/runner-helpers.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
2
|
-
import type { AgentProfile, ContextState, Message, ProviderCapabilities } from "agent-relay-sdk";
|
|
2
|
+
import type { AgentProfile, ContextState, Message, ProviderCapabilities, QuotaState } from "agent-relay-sdk";
|
|
3
3
|
import { errMessage } from "agent-relay-sdk";
|
|
4
4
|
import { targetMatchesAgentIdentity } from "agent-relay-sdk/agent-target";
|
|
5
5
|
import type { ProviderAdapter, ProviderConfig, RunnerSpawnConfig, SemanticStatus } from "./adapter";
|
|
@@ -192,7 +192,7 @@ export function lifecycleCapabilities(): Record<string, true> {
|
|
|
192
192
|
};
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
export function runtimeProviderCapabilities(options: RuntimeProviderOptions, contextState?: ContextState, probeModel?: ProbeModelInfo): ProviderCapabilities {
|
|
195
|
+
export function runtimeProviderCapabilities(options: RuntimeProviderOptions, contextState?: ContextState, probeModel?: ProbeModelInfo, quotaState?: QuotaState): ProviderCapabilities {
|
|
196
196
|
const model = options.model ?? probeModel?.model;
|
|
197
197
|
const effort = options.effort ?? probeModel?.effort;
|
|
198
198
|
const modelSource = options.model ? "runtime" as const : probeModel?.model ? "provider" as const : "runtime" as const;
|
|
@@ -225,6 +225,7 @@ export function runtimeProviderCapabilities(options: RuntimeProviderOptions, con
|
|
|
225
225
|
lastUpdatedAt: options.startedAt,
|
|
226
226
|
},
|
|
227
227
|
...runtimeProviderContextCapabilities(options, contextState),
|
|
228
|
+
...runtimeProviderQuotaCapabilities(options, quotaState),
|
|
228
229
|
...runtimeProviderTerminalCapabilities(options),
|
|
229
230
|
liveSession: {
|
|
230
231
|
capture: true,
|
|
@@ -324,6 +325,25 @@ function runtimeProviderContextCapabilities(options: RuntimeProviderOptions, con
|
|
|
324
325
|
return Object.keys(context).length ? { context } : {};
|
|
325
326
|
}
|
|
326
327
|
|
|
328
|
+
function runtimeProviderQuotaCapabilities(options: RuntimeProviderOptions, quotaState?: QuotaState): Pick<ProviderCapabilities, "quota"> {
|
|
329
|
+
if (options.provider !== "claude" && options.provider !== "codex") return {};
|
|
330
|
+
const fallbackWindows = options.provider === "claude"
|
|
331
|
+
? [{ name: "five_hour", unit: "%" as const, reset: true }, { name: "seven_day", unit: "%" as const, reset: true }]
|
|
332
|
+
: [{ name: "primary", unit: "%" as const, reset: true }, { name: "secondary", unit: "%" as const, reset: true }];
|
|
333
|
+
return {
|
|
334
|
+
quota: {
|
|
335
|
+
supported: true,
|
|
336
|
+
source: quotaState?.source ?? "probe",
|
|
337
|
+
unit: quotaState?.windows[0]?.unit ?? "%",
|
|
338
|
+
windows: quotaState?.windows.map((window) => ({
|
|
339
|
+
name: window.name,
|
|
340
|
+
unit: window.unit,
|
|
341
|
+
reset: window.resetsAt !== undefined,
|
|
342
|
+
})) ?? fallbackWindows,
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
327
347
|
export function providerStateFromActiveWork(activeWork: Array<{ kind: string; metadata?: Record<string, unknown> }>): Record<string, unknown> | null {
|
|
328
348
|
const providerTurn = activeWork.find((item) => item.kind === "provider-turn");
|
|
329
349
|
const state = providerTurn?.metadata?.providerState;
|