@runuai/host 0.9.161 → 0.9.163

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.
@@ -4,14 +4,17 @@
4
4
  * When an engine reports an error that means "this account is temporarily out
5
5
  * of capacity" (usage cap, 429, quota, overloaded), the orchestrator cools the
6
6
  * current account and fails over to the next one. Each adapter's pure mapper
7
- * runs the error text through {@link isRateLimitMessage} and tags the resulting
8
- * `error` event with `retryable: "rate_limit"` so rotation can act on it
9
- * without re-parsing engine-specific strings downstream.
7
+ * runs the error text through {@link isRateLimitMessage}, emits a secret-free
8
+ * account usage observation, and tags the resulting `error` event with
9
+ * `retryable: "rate_limit"` so rotation can act on it without re-parsing
10
+ * engine-specific strings downstream.
10
11
  *
11
12
  * Deliberately broad but anchored on unambiguous tokens — a false positive
12
13
  * only rotates to another account (cheap, self-correcting), a false negative
13
14
  * just surfaces the error as today.
14
15
  */
16
+ import type { AgentAccountUsage } from "./types";
17
+
15
18
  const RATE_LIMIT_PATTERNS: RegExp[] = [
16
19
  /rate[\s_-]?limit/i,
17
20
  /\b429\b/,
@@ -39,3 +42,296 @@ export function isRateLimitMessage(message: string | undefined | null): boolean
39
42
  if (!message) return false;
40
43
  return RATE_LIMIT_PATTERNS.some((re) => re.test(message));
41
44
  }
45
+
46
+ /** Build the account observation shared by every adapter's error-text path. */
47
+ export function rateLimitErrorUsage(
48
+ message: string,
49
+ now: number = Date.now(),
50
+ timeZone?: string,
51
+ ): AgentAccountUsage | null {
52
+ if (!isRateLimitMessage(message)) return null;
53
+ const resetsAt = parseResetHint(message, now, timeZone);
54
+ return {
55
+ status: "limited",
56
+ ...(resetsAt === null ? {} : { resetsAt }),
57
+ windows: [],
58
+ source: "error-text",
59
+ observedAt: now,
60
+ };
61
+ }
62
+
63
+ const MONTHS = new Map(
64
+ ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"].map(
65
+ (month, index) => [month, index + 1] as const,
66
+ ),
67
+ );
68
+ const MAX_RESET_SECONDS = 100_000_000_000;
69
+ const MAX_RESET_AHEAD_MS = 365 * 24 * 60 * 60_000;
70
+
71
+ /** Convert a provider Unix-seconds reset without accepting milliseconds or a
72
+ * corrupt timestamp that is more than a year beyond this observation. */
73
+ export function resetSecondsToEpochMs(
74
+ value: unknown,
75
+ observedAt: number,
76
+ ): number | undefined {
77
+ if (
78
+ typeof value !== "number" ||
79
+ !Number.isSafeInteger(value) ||
80
+ value <= 0 ||
81
+ value > MAX_RESET_SECONDS ||
82
+ !Number.isFinite(observedAt)
83
+ ) {
84
+ return undefined;
85
+ }
86
+ const candidate = value * 1_000;
87
+ return candidate <= observedAt + MAX_RESET_AHEAD_MS
88
+ ? candidate
89
+ : undefined;
90
+ }
91
+
92
+ interface CivilTime {
93
+ year: number;
94
+ month: number;
95
+ day: number;
96
+ hour: number;
97
+ minute: number;
98
+ second: number;
99
+ }
100
+
101
+ function formatter(timeZone: string): Intl.DateTimeFormat | null {
102
+ try {
103
+ return new Intl.DateTimeFormat("en-US-u-ca-gregory-nu-latn", {
104
+ timeZone,
105
+ year: "numeric",
106
+ month: "2-digit",
107
+ day: "2-digit",
108
+ hour: "2-digit",
109
+ minute: "2-digit",
110
+ second: "2-digit",
111
+ hourCycle: "h23",
112
+ });
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ function civilAt(epochMs: number, fmt: Intl.DateTimeFormat): CivilTime | null {
119
+ const values = new Map(
120
+ fmt
121
+ .formatToParts(new Date(epochMs))
122
+ .filter((part) => part.type !== "literal")
123
+ .map((part) => [part.type, Number(part.value)]),
124
+ );
125
+ const civil = {
126
+ year: values.get("year") ?? NaN,
127
+ month: values.get("month") ?? NaN,
128
+ day: values.get("day") ?? NaN,
129
+ hour: values.get("hour") ?? NaN,
130
+ minute: values.get("minute") ?? NaN,
131
+ second: values.get("second") ?? NaN,
132
+ };
133
+ return Object.values(civil).every(Number.isFinite) ? civil : null;
134
+ }
135
+
136
+ function sameCivil(left: CivilTime | null, right: CivilTime): boolean {
137
+ return (
138
+ left !== null &&
139
+ left.year === right.year &&
140
+ left.month === right.month &&
141
+ left.day === right.day &&
142
+ left.hour === right.hour &&
143
+ left.minute === right.minute &&
144
+ left.second === right.second
145
+ );
146
+ }
147
+
148
+ /** Convert a civil time in an IANA zone to an instant. Sampling the offsets
149
+ * on both sides of the date makes DST folds visible: zero or multiple matching
150
+ * instants are refused instead of guessing. */
151
+ function epochForCivil(civil: CivilTime, timeZone: string): number | null {
152
+ const fmt = formatter(timeZone);
153
+ if (!fmt) return null;
154
+ const target = Date.UTC(
155
+ civil.year,
156
+ civil.month - 1,
157
+ civil.day,
158
+ civil.hour,
159
+ civil.minute,
160
+ civil.second,
161
+ );
162
+ const normalized = new Date(target);
163
+ if (
164
+ normalized.getUTCFullYear() !== civil.year ||
165
+ normalized.getUTCMonth() + 1 !== civil.month ||
166
+ normalized.getUTCDate() !== civil.day ||
167
+ normalized.getUTCHours() !== civil.hour ||
168
+ normalized.getUTCMinutes() !== civil.minute
169
+ ) {
170
+ return null;
171
+ }
172
+
173
+ const offsets = new Set<number>();
174
+ for (let hours = -36; hours <= 36; hours += 6) {
175
+ const probe = target + hours * 60 * 60_000;
176
+ const parts = civilAt(probe, fmt);
177
+ if (!parts) continue;
178
+ offsets.add(
179
+ Date.UTC(
180
+ parts.year,
181
+ parts.month - 1,
182
+ parts.day,
183
+ parts.hour,
184
+ parts.minute,
185
+ parts.second,
186
+ ) - probe,
187
+ );
188
+ }
189
+ const candidates = [...offsets]
190
+ .map((offset) => target - offset)
191
+ .filter((candidate) => sameCivil(civilAt(candidate, fmt), civil));
192
+ const unique = [...new Set(candidates)];
193
+ return unique.length === 1 ? unique[0]! : null;
194
+ }
195
+
196
+ function localDate(now: number, timeZone: string): CivilTime | null {
197
+ const fmt = formatter(timeZone);
198
+ return fmt ? civilAt(now, fmt) : null;
199
+ }
200
+
201
+ function parsedZone(raw: string | undefined, fallback: string): string | null {
202
+ if (!raw) return fallback;
203
+ const zone = raw.trim();
204
+ if (/^(?:UTC|GMT)$/i.test(zone)) return "UTC";
205
+ // Abbreviations such as CST name several zones and are therefore unsafe.
206
+ if (!zone.includes("/")) return null;
207
+ return formatter(zone) ? zone : null;
208
+ }
209
+
210
+ function clockHour(rawHour: string, meridiem: string): number | null {
211
+ const hour = Number(rawHour);
212
+ if (!Number.isInteger(hour) || hour < 1 || hour > 12) return null;
213
+ return (hour % 12) + (meridiem.toLowerCase() === "pm" ? 12 : 0);
214
+ }
215
+
216
+ /**
217
+ * Parse the deliberately small set of reset hints emitted by engine CLIs.
218
+ * Returns epoch milliseconds, or null when the text is invalid, names an
219
+ * ambiguous civil instant, contains multiple hints, or needs assumptions the
220
+ * message did not supply.
221
+ */
222
+ export function parseResetHint(
223
+ message: string,
224
+ now: number,
225
+ timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
226
+ ): number | null {
227
+ if (!message || !Number.isFinite(now) || !formatter(timeZone)) return null;
228
+ const found: number[] = [];
229
+
230
+ // Claude also emits a machine-readable suffix in some terminal errors.
231
+ // It is Unix seconds; reject millisecond-scale or implausibly distant input
232
+ // rather than turning malformed text into a long account bench.
233
+ const claudeEpoch = /\bClaude AI usage limit reached\|(\d+)\b/gi;
234
+ for (const match of message.matchAll(claudeEpoch)) {
235
+ const seconds = Number(match[1]);
236
+ const candidate = resetSecondsToEpochMs(seconds, now);
237
+ if (candidate !== undefined && candidate > now) found.push(candidate);
238
+ }
239
+
240
+ const relative = /\b(?:try\s+again|resets?)\s+in\s+((?:\d+\s*(?:days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\s*){1,4})(?=$|[.,;!?])/gi;
241
+ for (const match of message.matchAll(relative)) {
242
+ const units = new Set<string>();
243
+ let duration = 0;
244
+ const token = /(\d+)\s*(days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)/gi;
245
+ let consumed = "";
246
+ for (const part of match[1]!.matchAll(token)) {
247
+ const value = Number(part[1]);
248
+ const rawUnit = part[2]!.toLowerCase();
249
+ const unit = rawUnit.startsWith("d")
250
+ ? "d"
251
+ : rawUnit.startsWith("h")
252
+ ? "h"
253
+ : rawUnit.startsWith("m")
254
+ ? "m"
255
+ : "s";
256
+ if (!Number.isSafeInteger(value) || value <= 0 || units.has(unit)) {
257
+ duration = NaN;
258
+ break;
259
+ }
260
+ units.add(unit);
261
+ consumed += part[0].replace(/\s+/g, "");
262
+ duration +=
263
+ value *
264
+ (unit === "d"
265
+ ? 24 * 60 * 60_000
266
+ : unit === "h"
267
+ ? 60 * 60_000
268
+ : unit === "m"
269
+ ? 60_000
270
+ : 1_000);
271
+ }
272
+ if (
273
+ Number.isSafeInteger(duration) &&
274
+ duration > 0 &&
275
+ consumed.toLowerCase() === match[1]!.replace(/\s+/g, "").toLowerCase()
276
+ ) {
277
+ found.push(now + duration);
278
+ }
279
+ }
280
+
281
+ const monthNames = "Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?";
282
+ const dated = new RegExp(
283
+ `\\bresets?\\s+(${monthNames})\\s+(\\d{1,2})(?:,?\\s+(\\d{4}))?(?:,?\\s+(\\d{1,2})(?::(\\d{2}))?\\s*(am|pm))?(?:\\s*\\(([^)]+)\\))?(?=$|[.,;!?])`,
284
+ "gi",
285
+ );
286
+ for (const match of message.matchAll(dated)) {
287
+ const month = MONTHS.get(match[1]!.slice(0, 3).toLowerCase());
288
+ const day = Number(match[2]);
289
+ const explicitYear = match[3] === undefined ? null : Number(match[3]);
290
+ const hour = match[4] === undefined ? 0 : clockHour(match[4], match[6]!);
291
+ const minute = match[5] === undefined ? 0 : Number(match[5]);
292
+ // The CLI runs in a container whose local zone need not match this host
293
+ // process. A calendar date is zone-sensitive even when it names no clock,
294
+ // so every absolute hint must name its zone.
295
+ const zone =
296
+ match[7] === undefined ? null : parsedZone(match[7], timeZone);
297
+ const base = zone ? localDate(now, zone) : null;
298
+ if (!base || !month || !zone || hour === null || minute < 0 || minute > 59) continue;
299
+ let year = explicitYear ?? base.year;
300
+ let candidate = epochForCivil({ year, month, day, hour, minute, second: 0 }, zone);
301
+ if (explicitYear === null && candidate !== null && candidate <= now) {
302
+ year += 1;
303
+ candidate = epochForCivil({ year, month, day, hour, minute, second: 0 }, zone);
304
+ }
305
+ if (candidate !== null && candidate > now) found.push(candidate);
306
+ }
307
+
308
+ const clock = /\bresets?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)(?:\s*\(([^)]+)\))?(?=$|[.,;!?])/gi;
309
+ for (const match of message.matchAll(clock)) {
310
+ const hour = clockHour(match[1]!, match[3]!);
311
+ const minute = match[2] === undefined ? 0 : Number(match[2]);
312
+ const zone = match[4] === undefined ? null : parsedZone(match[4], timeZone);
313
+ const base = zone ? localDate(now, zone) : null;
314
+ if (!base || hour === null || minute < 0 || minute > 59 || !zone) continue;
315
+ let candidate = epochForCivil(
316
+ { year: base.year, month: base.month, day: base.day, hour, minute, second: 0 },
317
+ zone,
318
+ );
319
+ if (candidate !== null && candidate <= now) {
320
+ const tomorrow = new Date(Date.UTC(base.year, base.month - 1, base.day + 1));
321
+ candidate = epochForCivil(
322
+ {
323
+ year: tomorrow.getUTCFullYear(),
324
+ month: tomorrow.getUTCMonth() + 1,
325
+ day: tomorrow.getUTCDate(),
326
+ hour,
327
+ minute,
328
+ second: 0,
329
+ },
330
+ zone,
331
+ );
332
+ }
333
+ if (candidate !== null && candidate > now) found.push(candidate);
334
+ }
335
+
336
+ return found.length === 1 ? found[0]! : null;
337
+ }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { z } from "zod";
15
15
 
16
+ import type { EngineUsageWindow } from "../../src/protocol";
16
17
  import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
17
18
 
18
19
  /** The host-advertised adapter kind ("claude" | "codex" | "gemini" | …). */
@@ -108,6 +109,24 @@ export interface AgentUsage {
108
109
  costUsd?: number;
109
110
  }
110
111
 
112
+ /** Secret-free account usage exactly as an engine CLI reported it. The
113
+ * orchestrator adds task/account identity only after proving the session is
114
+ * running on a cloud-leased account. */
115
+ export interface AgentAccountUsage {
116
+ status: "allowed" | "warning" | "limited";
117
+ resetsAt?: number;
118
+ usingOverage?: boolean;
119
+ windows: EngineUsageWindow[];
120
+ source: "claude-rate-limit-event" | "codex-rate-limits" | "error-text";
121
+ observedAt: number;
122
+ /** Which provider capacity this reading can prove usable again. Claude emits
123
+ * one event per named window; Codex snapshots cover every window. Internal
124
+ * runner metadata only: it is deliberately not part of engine.usage. */
125
+ coverage?:
126
+ | { kind: "window"; window: string }
127
+ | { kind: "all-windows" };
128
+ }
129
+
111
130
  export type AgentEvent =
112
131
  /** A chunk of streaming assistant text. Appended to the in-progress message. */
113
132
  | { type: "message_delta"; text: string }
@@ -129,10 +148,19 @@ export type AgentEvent =
129
148
  /** The turn (one request → response cycle) is done; agent is idle.
130
149
  * `usage` carries this turn's token/cost accounting when the CLI reports it. */
131
150
  | { type: "turn_complete"; usage?: AgentUsage }
151
+ /** Account-level capacity reported by the engine CLI. This never contains
152
+ * credentials or provider account identifiers. */
153
+ | { type: "account_usage"; usage: AgentAccountUsage }
132
154
  /** A recoverable error surfaced by the agent. `retryable` marks errors the
133
155
  * orchestrator can fail over from — "rate_limit" triggers account rotation
134
156
  * (ADR-076). */
135
- | { type: "error"; message: string; retryable?: "rate_limit" }
157
+ | {
158
+ type: "error";
159
+ message: string;
160
+ retryable?: "rate_limit";
161
+ /** Parsed from the same rate-limit error text, when unambiguous. */
162
+ resetsAt?: number;
163
+ }
136
164
  /** The underlying process exited. */
137
165
  | { type: "exit"; code: number };
138
166
 
package/lib/agents/uai.ts CHANGED
@@ -93,11 +93,10 @@ const create: AgentSessionFactory["create"] = async (args): Promise<AgentSession
93
93
  }
94
94
  const taskToken = args.agentEnv?.UAI_TASK_TOKEN;
95
95
  if (!taskToken) {
96
- // agentCliEnv mints no token for a permissionless non-secretary agent;
97
- // the gateway authenticates with exactly that token, so the engine
98
- // needs it. The default crew grants permissions, so this is rare.
96
+ // agentCliEnv mints one for every managed-engine agent, permissions or
97
+ // not; none here means the task has no owner, API url or CLI secret.
99
98
  throw new Error(
100
- "uai engine: this agent has no task token (no CLI permissions) — grant it at least one permission or use a direct engine",
99
+ "uai engine: this agent has no task token (the task has no CLI identity yet) — restart the task",
101
100
  );
102
101
  }
103
102
  return delegate.create({
@@ -440,7 +440,11 @@ export function engineAccountCount(
440
440
  // ---------------------------------------------------------------------------
441
441
 
442
442
  interface RotationState {
443
- cooldownUntil: number;
443
+ /** One account may be capped by several independent provider windows. Null
444
+ * is a rate-limit error that named no window and needs a full snapshot to
445
+ * disprove. Each cause keeps its own deadline so clearing one cannot clear
446
+ * or extend another. */
447
+ cooldowns: Map<string | null, number>;
444
448
  lastUsedAt: number;
445
449
  }
446
450
 
@@ -448,23 +452,97 @@ const rotationState = new Map<string, RotationState>();
448
452
 
449
453
  /** Default cooldown when the engine gives no reset hint. */
450
454
  export const DEFAULT_COOLDOWN_MS = 15 * 60_000;
455
+ /** Provider resets too close to the current instant still need a short bench
456
+ * so repeated terminal events cannot hot-loop the same account. */
457
+ export const MIN_ENGINE_ACCOUNT_COOLDOWN_MS = 60_000;
458
+ /** Refuse a corrupt/far-future provider timestamp from benching an account
459
+ * indefinitely. */
460
+ export const MAX_ENGINE_ACCOUNT_COOLDOWN_MS = 7 * 24 * 60 * 60_000;
451
461
 
452
462
  function state(id: string): RotationState {
453
463
  let st = rotationState.get(id);
454
464
  if (!st) {
455
- st = { cooldownUntil: 0, lastUsedAt: 0 };
465
+ st = { cooldowns: new Map(), lastUsedAt: 0 };
456
466
  rotationState.set(id, st);
457
467
  }
458
468
  return st;
459
469
  }
460
470
 
471
+ function cooldown(
472
+ id: string,
473
+ window: string | null,
474
+ ms: number,
475
+ now: number,
476
+ ): void {
477
+ const account = state(id);
478
+ const previous = account.cooldowns.get(window) ?? 0;
479
+ account.cooldowns.set(window, Math.max(previous, now + ms));
480
+ }
481
+
482
+ function cooldownDuration(resetsAt: number, now: number): number {
483
+ return Number.isFinite(resetsAt)
484
+ ? Math.min(
485
+ MAX_ENGINE_ACCOUNT_COOLDOWN_MS,
486
+ Math.max(MIN_ENGINE_ACCOUNT_COOLDOWN_MS, resetsAt - now),
487
+ )
488
+ : DEFAULT_COOLDOWN_MS;
489
+ }
490
+
491
+ function accountCooldownUntil(id: string): number {
492
+ return Math.max(0, ...state(id).cooldowns.values());
493
+ }
494
+
461
495
  /** Put an account on cooldown (rate-limited). */
462
496
  export function cooldownEngineAccount(
463
497
  id: string,
464
498
  ms: number = DEFAULT_COOLDOWN_MS,
465
499
  now: number = Date.now(),
466
500
  ): void {
467
- state(id).cooldownUntil = now + ms;
501
+ cooldown(id, null, ms, now);
502
+ }
503
+
504
+ /** Put an account on cooldown through the engine's real reset, bounded against
505
+ * stale or corrupt timestamps. */
506
+ export function cooldownEngineAccountUntil(
507
+ id: string,
508
+ resetsAt: number,
509
+ now: number = Date.now(),
510
+ ): void {
511
+ cooldown(id, null, cooldownDuration(resetsAt, now), now);
512
+ }
513
+
514
+ /** Bench one named provider window without conflating it with another. */
515
+ export function cooldownEngineAccountWindow(
516
+ id: string,
517
+ window: string,
518
+ ms: number = DEFAULT_COOLDOWN_MS,
519
+ now: number = Date.now(),
520
+ ): void {
521
+ cooldown(id, window, ms, now);
522
+ }
523
+
524
+ /** Bench one named provider window through its bounded real reset. */
525
+ export function cooldownEngineAccountWindowUntil(
526
+ id: string,
527
+ window: string,
528
+ resetsAt: number,
529
+ now: number = Date.now(),
530
+ ): void {
531
+ cooldown(id, window, cooldownDuration(resetsAt, now), now);
532
+ }
533
+
534
+ /** A per-window reading proves only that same provider window usable. */
535
+ export function clearEngineAccountCooldownWindow(
536
+ id: string,
537
+ window: string,
538
+ ): void {
539
+ rotationState.get(id)?.cooldowns.delete(window);
540
+ }
541
+
542
+ /** A complete provider snapshot with no limited window disproves every bench,
543
+ * including an error that could not name which window was exhausted. */
544
+ export function clearEngineAccountCooldowns(id: string): void {
545
+ rotationState.get(id)?.cooldowns.clear();
468
546
  }
469
547
 
470
548
  /** Record that an account was just used (least-recently-used ordering). */
@@ -487,7 +565,7 @@ export function selectAccount(
487
565
  const candidates = accounts.filter((a) => !exclude.has(a.id));
488
566
  if (candidates.length === 0) return null;
489
567
  const lru = (a: ResolvedEngineAccount): number => state(a.id).lastUsedAt;
490
- const healthy = candidates.filter((a) => state(a.id).cooldownUntil <= now);
568
+ const healthy = candidates.filter((a) => accountCooldownUntil(a.id) <= now);
491
569
  // Manage-v1: the list arrives in PRIORITY order (default slot, then extras
492
570
  // by position) — the first healthy account wins, so the operator's
493
571
  // ordering decides which account a task uses and which are fallbacks.