@mcoda/agents 0.1.27 → 0.1.28

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.
@@ -1,9 +1,14 @@
1
1
  import { Agent, AgentAuthMetadata, AgentHealth, AgentPromptManifest } from "@mcoda/shared";
2
2
  import { GlobalRepository } from "@mcoda/db";
3
3
  import { AgentAdapter, InvocationRequest, InvocationResult } from "../adapters/AdapterTypes.js";
4
+ interface AgentServiceOptions {
5
+ now?: () => number;
6
+ sleep?: (ms: number) => Promise<void>;
7
+ }
4
8
  export declare class AgentService {
5
9
  private repo;
6
- constructor(repo: GlobalRepository);
10
+ private options;
11
+ constructor(repo: GlobalRepository, options?: AgentServiceOptions);
7
12
  static create(): Promise<AgentService>;
8
13
  close(): Promise<void>;
9
14
  resolveAgent(identifier: string): Promise<Agent>;
@@ -14,6 +19,18 @@ export declare class AgentService {
14
19
  private buildAdapterConfig;
15
20
  private resolveAdapterType;
16
21
  getAdapter(agent: Agent, adapterOverride?: string): Promise<AgentAdapter>;
22
+ private nowMs;
23
+ private sleepMs;
24
+ private sleepUntil;
25
+ private metric;
26
+ private estimateWindowResetMs;
27
+ private estimateResetMsFromWindowTypes;
28
+ private normalizeLimitKey;
29
+ private getAgentAvailability;
30
+ private listEquivalentAgents;
31
+ private findNextAvailableAgent;
32
+ private findEarliestResetMs;
33
+ private persistUsageLimitObservation;
17
34
  healthCheck(agentId: string): Promise<AgentHealth>;
18
35
  invoke(agentId: string, request: InvocationRequest): Promise<InvocationResult>;
19
36
  invokeStream(agentId: string, request: InvocationRequest): Promise<AsyncGenerator<InvocationResult>>;
@@ -21,4 +38,5 @@ export declare class AgentService {
21
38
  private recordInvocationFailure;
22
39
  private applyDocdexGuidance;
23
40
  }
41
+ export {};
24
42
  //# sourceMappingURL=AgentService.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"AgentService.d.ts","sourceRoot":"","sources":["../../src/AgentService/AgentService.ts"],"names":[],"mappings":"AAGA,OAAO,EAAgB,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACzG,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAY7C,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AA6KhG,qBAAa,YAAY;IACX,OAAO,CAAC,IAAI;gBAAJ,IAAI,EAAE,gBAAgB;WAE7B,MAAM,IAAI,OAAO,CAAC,YAAY,CAAC;IAKtC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAItB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;IAUhD,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,SAAS,CAAC;IAIrE,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAInD,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC;YAIpD,kBAAkB;YAMlB,kBAAkB;IA6BhC,OAAO,CAAC,kBAAkB;IA+BpB,UAAU,CAAC,KAAK,EAAE,KAAK,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IA4CzE,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IAmBlD,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAyB9E,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,cAAc,CAAC,gBAAgB,CAAC,CAAC;YAyC5F,mBAAmB;YAkBnB,uBAAuB;YAwBvB,mBAAmB;CAYlC"}
1
+ {"version":3,"file":"AgentService.d.ts","sourceRoot":"","sources":["../../src/AgentService/AgentService.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,EACL,iBAAiB,EACjB,WAAW,EACX,mBAAmB,EAKpB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAY7C,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAsLhG,UAAU,mBAAmB;IAC3B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,qBAAa,YAAY;IAErB,OAAO,CAAC,IAAI;IACZ,OAAO,CAAC,OAAO;gBADP,IAAI,EAAE,gBAAgB,EACtB,OAAO,GAAE,mBAAwB;WAG9B,MAAM,IAAI,OAAO,CAAC,YAAY,CAAC;IAKtC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAItB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;IAUhD,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,SAAS,CAAC;IAIrE,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAInD,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC;YAIpD,kBAAkB;YAMlB,kBAAkB;IA6BhC,OAAO,CAAC,kBAAkB;IA+BpB,UAAU,CAAC,KAAK,EAAE,KAAK,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IA4C/E,OAAO,CAAC,KAAK;YAIC,OAAO;YASP,UAAU;IAQxB,OAAO,CAAC,MAAM;IAId,OAAO,CAAC,qBAAqB;IAI7B,OAAO,CAAC,8BAA8B;IAStC,OAAO,CAAC,iBAAiB;YAOX,oBAAoB;YA6BpB,oBAAoB;YA6CpB,sBAAsB;YAatB,mBAAmB;YAUnB,4BAA4B;IAkCpC,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IAmBlD,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA2E9E,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,cAAc,CAAC,gBAAgB,CAAC,CAAC;YA0I5F,mBAAmB;YAkBnB,uBAAuB;YAwBvB,mBAAmB;CAYlC"}
@@ -1,7 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { CryptoHelper } from "@mcoda/shared";
4
+ import { CryptoHelper, } from "@mcoda/shared";
5
5
  import { GlobalRepository } from "@mcoda/db";
6
6
  import { CodexAdapter } from "../adapters/codex/CodexAdapter.js";
7
7
  import { GeminiAdapter } from "../adapters/gemini/GeminiAdapter.js";
@@ -14,6 +14,7 @@ import { OpenAiCliAdapter } from "../adapters/openai/OpenAiCliAdapter.js";
14
14
  import { ZhipuApiAdapter } from "../adapters/zhipu/ZhipuApiAdapter.js";
15
15
  import { QaAdapter } from "../adapters/qa/QaAdapter.js";
16
16
  import { ClaudeAdapter } from "../adapters/claude/ClaudeAdapter.js";
17
+ import { parseUsageLimitError } from "./UsageLimitParser.js";
17
18
  const CLI_BASED_ADAPTERS = new Set(["codex-cli", "gemini-cli", "openai-cli", "ollama-cli", "codali-cli", "claude-cli"]);
18
19
  const LOCAL_ADAPTERS = new Set(["local-model"]);
19
20
  const SUPPORTED_ADAPTERS = new Set([
@@ -43,6 +44,14 @@ let docdexGuidanceCache;
43
44
  let docdexGuidanceLoaded = false;
44
45
  const DOCDEX_JSON_ONLY_MARKERS = [/output json only/i, /return json only/i, /no prose, no analysis/i];
45
46
  const HANDOFF_END_MARKERS = [/^\s*END OF FILE\s*$/i, /^\s*\*\*\* End of File\s*$/i];
47
+ const EQUIVALENCE_THRESHOLD = 1.5;
48
+ const MAX_SLEEP_CHUNK_MS = 60 * 60 * 1000;
49
+ const WINDOW_RESET_FALLBACK_MS = {
50
+ rolling_5h: 5 * 60 * 60 * 1000,
51
+ daily: 24 * 60 * 60 * 1000,
52
+ weekly: 7 * 24 * 60 * 60 * 1000,
53
+ other: 60 * 60 * 1000,
54
+ };
46
55
  const isIoEnabled = () => {
47
56
  const raw = process.env[IO_ENV];
48
57
  if (!raw)
@@ -179,8 +188,9 @@ const normalizeDocdexGuidanceInput = (input, prefix) => {
179
188
  return `${prefix}${remainder}`;
180
189
  };
181
190
  export class AgentService {
182
- constructor(repo) {
191
+ constructor(repo, options = {}) {
183
192
  this.repo = repo;
193
+ this.options = options;
184
194
  }
185
195
  static async create() {
186
196
  const repo = await GlobalRepository.create();
@@ -313,6 +323,180 @@ export class AgentService {
313
323
  }
314
324
  throw new Error(`Unsupported adapter type: ${adapterType}`);
315
325
  }
326
+ nowMs() {
327
+ return this.options.now ? this.options.now() : Date.now();
328
+ }
329
+ async sleepMs(ms) {
330
+ if (ms <= 0)
331
+ return;
332
+ if (this.options.sleep) {
333
+ await this.options.sleep(ms);
334
+ return;
335
+ }
336
+ await new Promise((resolve) => setTimeout(resolve, ms));
337
+ }
338
+ async sleepUntil(timestampMs) {
339
+ while (true) {
340
+ const remaining = timestampMs - this.nowMs();
341
+ if (remaining <= 0)
342
+ return;
343
+ await this.sleepMs(Math.min(remaining, MAX_SLEEP_CHUNK_MS));
344
+ }
345
+ }
346
+ metric(value, fallback = 5) {
347
+ return Number.isFinite(value) ? Number(value) : fallback;
348
+ }
349
+ estimateWindowResetMs(windowType, nowMs) {
350
+ return nowMs + (WINDOW_RESET_FALLBACK_MS[windowType] ?? WINDOW_RESET_FALLBACK_MS.other);
351
+ }
352
+ estimateResetMsFromWindowTypes(windowTypes, nowMs) {
353
+ const normalized = windowTypes.length ? windowTypes : ["other"];
354
+ let earliest = Number.POSITIVE_INFINITY;
355
+ for (const windowType of normalized) {
356
+ earliest = Math.min(earliest, this.estimateWindowResetMs(windowType, nowMs));
357
+ }
358
+ return Number.isFinite(earliest) ? earliest : this.estimateWindowResetMs("other", nowMs);
359
+ }
360
+ normalizeLimitKey(agent) {
361
+ const model = (agent.defaultModel ?? "").trim().toLowerCase();
362
+ if (model.includes("codex-spark"))
363
+ return "codex-spark";
364
+ if (model.includes("codex"))
365
+ return "codex-main";
366
+ return model || (agent.slug ?? agent.id).trim().toLowerCase();
367
+ }
368
+ async getAgentAvailability(agent, nowMs) {
369
+ const limits = await this.repo.listAgentUsageLimits(agent.id);
370
+ if (!limits.length)
371
+ return { available: true };
372
+ const limitKey = this.normalizeLimitKey(agent);
373
+ const relevant = limits.filter((entry) => {
374
+ if (entry.limitScope === "model") {
375
+ return entry.limitKey === limitKey;
376
+ }
377
+ return true;
378
+ });
379
+ if (!relevant.length)
380
+ return { available: true };
381
+ let earliestResetMs;
382
+ for (const entry of relevant) {
383
+ if (entry.status !== "exhausted")
384
+ continue;
385
+ let resetMs = entry.resetAt ? Date.parse(entry.resetAt) : Number.NaN;
386
+ if (!Number.isFinite(resetMs)) {
387
+ resetMs = this.estimateWindowResetMs(entry.windowType, nowMs);
388
+ }
389
+ if (resetMs > nowMs) {
390
+ earliestResetMs = earliestResetMs === undefined ? resetMs : Math.min(earliestResetMs, resetMs);
391
+ }
392
+ }
393
+ if (earliestResetMs !== undefined)
394
+ return { available: false, earliestResetMs };
395
+ return { available: true };
396
+ }
397
+ async listEquivalentAgents(baseAgent) {
398
+ const baseCapabilities = await this.repo.getAgentCapabilities(baseAgent.id);
399
+ const allAgents = await this.repo.listAgents();
400
+ const healthRows = await this.repo.listAgentHealthSummary();
401
+ const healthByAgentId = new Map(healthRows.map((entry) => [entry.agentId, entry]));
402
+ const baseRating = this.metric(baseAgent.rating);
403
+ const baseReasoning = this.metric(baseAgent.reasoningRating, baseRating);
404
+ const baseComplexity = this.metric(baseAgent.maxComplexity);
405
+ const candidates = [];
406
+ for (const candidate of allAgents) {
407
+ if (candidate.id === baseAgent.id)
408
+ continue;
409
+ const health = healthByAgentId.get(candidate.id);
410
+ if (health?.status === "unreachable")
411
+ continue;
412
+ const candidateCapabilities = await this.repo.getAgentCapabilities(candidate.id);
413
+ const hasAllCapabilities = baseCapabilities.every((capability) => candidateCapabilities.includes(capability));
414
+ if (!hasAllCapabilities)
415
+ continue;
416
+ const candidateRating = this.metric(candidate.rating);
417
+ const candidateReasoning = this.metric(candidate.reasoningRating, candidateRating);
418
+ const candidateComplexity = this.metric(candidate.maxComplexity);
419
+ const ratingDiff = Math.abs(candidateRating - baseRating);
420
+ const reasoningDiff = Math.abs(candidateReasoning - baseReasoning);
421
+ const complexityDiff = Math.abs(candidateComplexity - baseComplexity);
422
+ if (ratingDiff > EQUIVALENCE_THRESHOLD)
423
+ continue;
424
+ if (reasoningDiff > EQUIVALENCE_THRESHOLD)
425
+ continue;
426
+ if (complexityDiff > EQUIVALENCE_THRESHOLD)
427
+ continue;
428
+ candidates.push({
429
+ agent: candidate,
430
+ distance: ratingDiff + reasoningDiff + complexityDiff,
431
+ });
432
+ }
433
+ candidates.sort((left, right) => {
434
+ if (left.distance !== right.distance)
435
+ return left.distance - right.distance;
436
+ const leftRating = this.metric(left.agent.rating);
437
+ const rightRating = this.metric(right.agent.rating);
438
+ if (rightRating !== leftRating)
439
+ return rightRating - leftRating;
440
+ const leftCost = Number.isFinite(left.agent.costPerMillion) ? Number(left.agent.costPerMillion) : Number.POSITIVE_INFINITY;
441
+ const rightCost = Number.isFinite(right.agent.costPerMillion)
442
+ ? Number(right.agent.costPerMillion)
443
+ : Number.POSITIVE_INFINITY;
444
+ if (leftCost !== rightCost)
445
+ return leftCost - rightCost;
446
+ return (left.agent.slug ?? left.agent.id).localeCompare(right.agent.slug ?? right.agent.id);
447
+ });
448
+ return candidates.map((entry) => entry.agent);
449
+ }
450
+ async findNextAvailableAgent(candidates, attemptedAgentIds, nowMs) {
451
+ for (const candidate of candidates) {
452
+ if (attemptedAgentIds.has(candidate.id))
453
+ continue;
454
+ const availability = await this.getAgentAvailability(candidate, nowMs);
455
+ if (availability.available)
456
+ return candidate;
457
+ }
458
+ return undefined;
459
+ }
460
+ async findEarliestResetMs(candidates, nowMs) {
461
+ let earliest;
462
+ for (const candidate of candidates) {
463
+ const availability = await this.getAgentAvailability(candidate, nowMs);
464
+ if (!availability.earliestResetMs)
465
+ continue;
466
+ earliest = earliest === undefined ? availability.earliestResetMs : Math.min(earliest, availability.earliestResetMs);
467
+ }
468
+ return earliest;
469
+ }
470
+ async persistUsageLimitObservation(agent, observation) {
471
+ const observedAt = new Date(this.nowMs()).toISOString();
472
+ const limitKey = this.normalizeLimitKey(agent);
473
+ const windowTypes = observation.windowTypes.length
474
+ ? observation.windowTypes
475
+ : ["other"];
476
+ const fallbackResetMs = this.estimateResetMsFromWindowTypes(windowTypes, this.nowMs());
477
+ const resolvedResetAt = observation.resetAt ?? new Date(fallbackResetMs).toISOString();
478
+ const resetAtSource = observation.resetAt
479
+ ? observation.resetAtSource ?? "unknown"
480
+ : "estimated_window_fallback";
481
+ const records = windowTypes.map((windowType) => ({
482
+ agentId: agent.id,
483
+ limitScope: "model",
484
+ limitKey,
485
+ windowType,
486
+ status: "exhausted",
487
+ resetAt: resolvedResetAt,
488
+ observedAt,
489
+ source: "invoke_error_parse",
490
+ details: {
491
+ message: observation.message,
492
+ rawText: observation.rawText.slice(0, 4000),
493
+ resetAtSource,
494
+ resetAtProvided: Boolean(observation.resetAt),
495
+ estimatedResetAt: observation.resetAt ? undefined : resolvedResetAt,
496
+ },
497
+ }));
498
+ return this.repo.upsertAgentUsageLimits(records);
499
+ }
316
500
  async healthCheck(agentId) {
317
501
  const agent = await this.resolveAgent(agentId);
318
502
  try {
@@ -333,71 +517,205 @@ export class AgentService {
333
517
  }
334
518
  }
335
519
  async invoke(agentId, request) {
336
- const agent = await this.resolveAgent(agentId);
337
- const adapter = await this.getAdapter(agent, request.adapterType);
338
- if (!adapter.invoke) {
339
- throw new Error("Adapter does not support invoke");
340
- }
341
- const withDocdex = await this.applyDocdexGuidance(request);
342
- const enriched = await this.applyGatewayHandoff(withDocdex);
343
- const ioEnabled = isIoEnabled();
344
- if (ioEnabled) {
345
- renderIoHeader(agent, enriched, "invoke");
346
- }
347
- try {
348
- const result = await adapter.invoke(enriched);
349
- if (ioEnabled) {
350
- renderIoChunk(result);
351
- renderIoEnd();
520
+ const baseAgent = await this.resolveAgent(agentId);
521
+ const equivalentAgents = await this.listEquivalentAgents(baseAgent);
522
+ const attemptedAgentIds = new Set();
523
+ const failoverEvents = [];
524
+ let activeAgent = baseAgent;
525
+ for (;;) {
526
+ attemptedAgentIds.add(activeAgent.id);
527
+ const adapter = await this.getAdapter(activeAgent, request.adapterType);
528
+ if (!adapter.invoke) {
529
+ throw new Error("Adapter does not support invoke");
352
530
  }
353
- return result;
354
- }
355
- catch (error) {
356
- await this.recordInvocationFailure(agent, error);
357
- throw error;
358
- }
359
- }
360
- async invokeStream(agentId, request) {
361
- const agent = await this.resolveAgent(agentId);
362
- const adapter = await this.getAdapter(agent, request.adapterType);
363
- if (!adapter.invokeStream) {
364
- throw new Error("Adapter does not support streaming");
365
- }
366
- const withDocdex = await this.applyDocdexGuidance(request);
367
- const enriched = await this.applyGatewayHandoff(withDocdex);
368
- const ioEnabled = isIoEnabled();
369
- let generator;
370
- try {
371
- generator = await adapter.invokeStream(enriched);
372
- }
373
- catch (error) {
374
- await this.recordInvocationFailure(agent, error);
375
- throw error;
376
- }
377
- const recordFailure = (error) => this.recordInvocationFailure(agent, error);
378
- async function* wrap() {
379
- const streamIo = ioEnabled ? createStreamIoRenderer() : undefined;
531
+ const withDocdex = await this.applyDocdexGuidance(request);
532
+ const enriched = await this.applyGatewayHandoff(withDocdex);
533
+ const ioEnabled = isIoEnabled();
380
534
  if (ioEnabled) {
381
- renderIoHeader(agent, enriched, "stream");
535
+ renderIoHeader(activeAgent, enriched, "invoke");
382
536
  }
383
537
  try {
384
- for await (const chunk of generator) {
385
- if (ioEnabled) {
386
- streamIo?.push(chunk);
387
- }
388
- yield chunk;
538
+ const result = await adapter.invoke(enriched);
539
+ if (ioEnabled) {
540
+ renderIoChunk(result);
541
+ renderIoEnd();
389
542
  }
543
+ if (!failoverEvents.length)
544
+ return result;
545
+ return {
546
+ ...result,
547
+ metadata: {
548
+ ...(result.metadata ?? {}),
549
+ failoverEvents,
550
+ },
551
+ };
390
552
  }
391
553
  catch (error) {
392
- await recordFailure(error);
393
- throw error;
554
+ await this.recordInvocationFailure(activeAgent, error);
555
+ const parsedLimit = parseUsageLimitError(error, this.nowMs());
556
+ if (!parsedLimit) {
557
+ throw error;
558
+ }
559
+ await this.persistUsageLimitObservation(activeAgent, parsedLimit);
560
+ const nowMs = this.nowMs();
561
+ const nextAgent = await this.findNextAvailableAgent([baseAgent, ...equivalentAgents], attemptedAgentIds, nowMs);
562
+ if (nextAgent) {
563
+ failoverEvents.push({
564
+ type: "switch_agent",
565
+ at: new Date(nowMs).toISOString(),
566
+ fromAgentId: activeAgent.id,
567
+ fromAgentSlug: activeAgent.slug,
568
+ toAgentId: nextAgent.id,
569
+ toAgentSlug: nextAgent.slug,
570
+ });
571
+ activeAgent = nextAgent;
572
+ continue;
573
+ }
574
+ const earliestResetMs = await this.findEarliestResetMs([baseAgent, ...equivalentAgents], nowMs);
575
+ const fallbackResetMs = this.estimateResetMsFromWindowTypes(parsedLimit.windowTypes, nowMs);
576
+ const waitUntilMs = earliestResetMs && earliestResetMs > nowMs ? earliestResetMs : Math.max(fallbackResetMs, nowMs + 1000);
577
+ const durationMs = waitUntilMs - nowMs;
578
+ failoverEvents.push({
579
+ type: "sleep_until_reset",
580
+ at: new Date(nowMs).toISOString(),
581
+ until: new Date(waitUntilMs).toISOString(),
582
+ durationMs,
583
+ });
584
+ await this.sleepUntil(waitUntilMs);
585
+ attemptedAgentIds.clear();
586
+ activeAgent = baseAgent;
394
587
  }
395
- if (ioEnabled) {
396
- streamIo?.flush();
397
- renderIoEnd();
588
+ }
589
+ }
590
+ async invokeStream(agentId, request) {
591
+ const baseAgent = await this.resolveAgent(agentId);
592
+ const equivalentAgents = await this.listEquivalentAgents(baseAgent);
593
+ const attemptedAgentIds = new Set();
594
+ const self = this;
595
+ async function* run() {
596
+ let activeAgent = baseAgent;
597
+ const failoverEvents = [];
598
+ for (;;) {
599
+ attemptedAgentIds.add(activeAgent.id);
600
+ const adapter = await self.getAdapter(activeAgent, request.adapterType);
601
+ if (!adapter.invokeStream) {
602
+ throw new Error("Adapter does not support streaming");
603
+ }
604
+ const withDocdex = await self.applyDocdexGuidance(request);
605
+ const enriched = await self.applyGatewayHandoff(withDocdex);
606
+ const ioEnabled = isIoEnabled();
607
+ let generator;
608
+ try {
609
+ generator = await adapter.invokeStream(enriched);
610
+ }
611
+ catch (error) {
612
+ await self.recordInvocationFailure(activeAgent, error);
613
+ const parsedLimit = parseUsageLimitError(error, self.nowMs());
614
+ if (!parsedLimit)
615
+ throw error;
616
+ await self.persistUsageLimitObservation(activeAgent, parsedLimit);
617
+ const nowMs = self.nowMs();
618
+ const nextAgent = await self.findNextAvailableAgent([baseAgent, ...equivalentAgents], attemptedAgentIds, nowMs);
619
+ if (nextAgent) {
620
+ failoverEvents.push({
621
+ type: "switch_agent",
622
+ at: new Date(nowMs).toISOString(),
623
+ fromAgentId: activeAgent.id,
624
+ fromAgentSlug: activeAgent.slug,
625
+ toAgentId: nextAgent.id,
626
+ toAgentSlug: nextAgent.slug,
627
+ });
628
+ activeAgent = nextAgent;
629
+ continue;
630
+ }
631
+ const earliestResetMs = await self.findEarliestResetMs([baseAgent, ...equivalentAgents], nowMs);
632
+ const fallbackResetMs = self.estimateResetMsFromWindowTypes(parsedLimit.windowTypes, nowMs);
633
+ const waitUntilMs = earliestResetMs && earliestResetMs > nowMs ? earliestResetMs : Math.max(fallbackResetMs, nowMs + 1000);
634
+ const durationMs = waitUntilMs - nowMs;
635
+ failoverEvents.push({
636
+ type: "sleep_until_reset",
637
+ at: new Date(nowMs).toISOString(),
638
+ until: new Date(waitUntilMs).toISOString(),
639
+ durationMs,
640
+ });
641
+ await self.sleepUntil(waitUntilMs);
642
+ attemptedAgentIds.clear();
643
+ activeAgent = baseAgent;
644
+ continue;
645
+ }
646
+ const streamIo = ioEnabled ? createStreamIoRenderer() : undefined;
647
+ if (ioEnabled) {
648
+ renderIoHeader(activeAgent, enriched, "stream");
649
+ }
650
+ let emitted = false;
651
+ try {
652
+ for await (const chunk of generator) {
653
+ emitted = true;
654
+ const chunkWithMetadata = failoverEvents.length > 0
655
+ ? {
656
+ ...chunk,
657
+ metadata: {
658
+ ...(chunk.metadata ?? {}),
659
+ failoverEvents: failoverEvents.map((event) => ({ ...event })),
660
+ },
661
+ }
662
+ : chunk;
663
+ if (ioEnabled) {
664
+ streamIo?.push(chunkWithMetadata);
665
+ }
666
+ yield chunkWithMetadata;
667
+ }
668
+ if (ioEnabled) {
669
+ streamIo?.flush();
670
+ renderIoEnd();
671
+ }
672
+ return;
673
+ }
674
+ catch (error) {
675
+ await self.recordInvocationFailure(activeAgent, error);
676
+ const parsedLimit = parseUsageLimitError(error, self.nowMs());
677
+ if (!parsedLimit)
678
+ throw error;
679
+ const nowMs = self.nowMs();
680
+ if (emitted) {
681
+ failoverEvents.push({
682
+ type: "stream_restart_after_limit",
683
+ at: new Date(nowMs).toISOString(),
684
+ fromAgentId: activeAgent.id,
685
+ fromAgentSlug: activeAgent.slug,
686
+ });
687
+ }
688
+ await self.persistUsageLimitObservation(activeAgent, parsedLimit);
689
+ const nextAgent = await self.findNextAvailableAgent([baseAgent, ...equivalentAgents], attemptedAgentIds, nowMs);
690
+ if (nextAgent) {
691
+ failoverEvents.push({
692
+ type: "switch_agent",
693
+ at: new Date(nowMs).toISOString(),
694
+ fromAgentId: activeAgent.id,
695
+ fromAgentSlug: activeAgent.slug,
696
+ toAgentId: nextAgent.id,
697
+ toAgentSlug: nextAgent.slug,
698
+ });
699
+ activeAgent = nextAgent;
700
+ continue;
701
+ }
702
+ const earliestResetMs = await self.findEarliestResetMs([baseAgent, ...equivalentAgents], nowMs);
703
+ const fallbackResetMs = self.estimateResetMsFromWindowTypes(parsedLimit.windowTypes, nowMs);
704
+ const waitUntilMs = earliestResetMs && earliestResetMs > nowMs ? earliestResetMs : Math.max(fallbackResetMs, nowMs + 1000);
705
+ const durationMs = waitUntilMs - nowMs;
706
+ failoverEvents.push({
707
+ type: "sleep_until_reset",
708
+ at: new Date(nowMs).toISOString(),
709
+ until: new Date(waitUntilMs).toISOString(),
710
+ durationMs,
711
+ });
712
+ await self.sleepUntil(waitUntilMs);
713
+ attemptedAgentIds.clear();
714
+ activeAgent = baseAgent;
715
+ }
398
716
  }
399
717
  }
400
- return wrap();
718
+ return run();
401
719
  }
402
720
  async applyGatewayHandoff(request) {
403
721
  if (request.metadata?.command === "gateway-agent") {
@@ -0,0 +1,13 @@
1
+ import { AgentUsageLimitWindowType } from "@mcoda/shared";
2
+ export type UsageLimitResetSource = "header" | "relative" | "absolute";
3
+ export interface ParsedUsageLimitError {
4
+ isUsageLimit: true;
5
+ message: string;
6
+ rawText: string;
7
+ windowTypes: AgentUsageLimitWindowType[];
8
+ resetAt?: string;
9
+ resetAtSource?: UsageLimitResetSource;
10
+ }
11
+ export declare const extractUsageLimitErrorText: (error: unknown) => string;
12
+ export declare const parseUsageLimitError: (error: unknown, nowMs?: number) => ParsedUsageLimitError | null;
13
+ //# sourceMappingURL=UsageLimitParser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UsageLimitParser.d.ts","sourceRoot":"","sources":["../../src/AgentService/UsageLimitParser.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,MAAM,eAAe,CAAC;AAqC1D,MAAM,MAAM,qBAAqB,GAAG,QAAQ,GAAG,UAAU,GAAG,UAAU,CAAC;AAEvE,MAAM,WAAW,qBAAqB;IACpC,YAAY,EAAE,IAAI,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,yBAAyB,EAAE,CAAC;IACzC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,qBAAqB,CAAC;CACvC;AAkJD,eAAO,MAAM,0BAA0B,GAAI,OAAO,OAAO,KAAG,MAI3D,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAAI,OAAO,OAAO,EAAE,cAAkB,KAAG,qBAAqB,GAAG,IA6BjG,CAAC"}
@@ -0,0 +1,197 @@
1
+ const LIMIT_PATTERNS = [
2
+ /usage[_\s-]*limit/i,
3
+ /usage_limit_reached/i,
4
+ /rate[_\s-]*limit/i,
5
+ /too many requests/i,
6
+ /\b429\b/,
7
+ /quota exceeded/i,
8
+ /retry after/i,
9
+ ];
10
+ const NON_LIMIT_AUTH_PATTERNS = [
11
+ /invalid[_\s-]*api[_\s-]*key/i,
12
+ /api key.*invalid/i,
13
+ /authentication failed/i,
14
+ /missing api key/i,
15
+ /unauthorized/i,
16
+ ];
17
+ const WINDOW_HINTS = [
18
+ { pattern: /\b(?:5\s*(?:h|hr|hrs|hour|hours)|five[-\s]?hour)\b/i, windowType: "rolling_5h" },
19
+ { pattern: /\b(?:daily|24\s*(?:h|hr|hrs|hour|hours))\b/i, windowType: "daily" },
20
+ { pattern: /\b(?:weekly|7\s*(?:d|day|days))\b/i, windowType: "weekly" },
21
+ ];
22
+ const RELATIVE_RESET_PATTERNS = [
23
+ /retry after\s+([^\n.;]+)/i,
24
+ /try again in\s+([^\n.;]+)/i,
25
+ /resets?\s+in\s+([^\n.;]+)/i,
26
+ ];
27
+ const ISO_LIKE_PATTERN = /\b(20\d{2}-\d{2}-\d{2}[tT ]\d{2}:\d{2}(?::\d{2})?(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2}| ?UTC| ?GMT)?)\b/g;
28
+ const EPOCH_RESET_PATTERN = /\bx-ratelimit-reset(?:-at)?\s*[:=]\s*(\d{10,13})\b/i;
29
+ const SHORT_RESET_PATTERN = /\bx-ratelimit-reset-after\s*[:=]\s*(\d+)\b/i;
30
+ const normalizeSpace = (value) => value.replace(/\s+/g, " ").trim();
31
+ const collectText = (value, out, depth = 0) => {
32
+ if (depth > 4 || value === null || value === undefined)
33
+ return;
34
+ if (typeof value === "string") {
35
+ const trimmed = value.trim();
36
+ if (trimmed)
37
+ out.push(trimmed);
38
+ return;
39
+ }
40
+ if (typeof value === "number" || typeof value === "boolean") {
41
+ out.push(String(value));
42
+ return;
43
+ }
44
+ if (value instanceof Error) {
45
+ collectText(value.message, out, depth + 1);
46
+ const extra = value;
47
+ for (const [key, nested] of Object.entries(extra)) {
48
+ if (key === "message" || key === "name" || key === "stack")
49
+ continue;
50
+ collectText(nested, out, depth + 1);
51
+ }
52
+ return;
53
+ }
54
+ if (Array.isArray(value)) {
55
+ for (const item of value)
56
+ collectText(item, out, depth + 1);
57
+ return;
58
+ }
59
+ if (typeof value === "object") {
60
+ for (const [key, nested] of Object.entries(value)) {
61
+ if (/(error|message|reason|stderr|stdout|detail|details|body)/i.test(key)) {
62
+ collectText(nested, out, depth + 1);
63
+ continue;
64
+ }
65
+ if (depth < 2) {
66
+ collectText(nested, out, depth + 1);
67
+ }
68
+ }
69
+ }
70
+ };
71
+ const parseDurationMs = (input) => {
72
+ let totalMs = 0;
73
+ let matched = false;
74
+ const pattern = /(\d+)\s*(weeks?|w|days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\b/gi;
75
+ let match = pattern.exec(input);
76
+ while (match) {
77
+ const amount = Number.parseInt(match[1] ?? "", 10);
78
+ const unit = (match[2] ?? "").toLowerCase();
79
+ if (Number.isFinite(amount) && amount > 0) {
80
+ matched = true;
81
+ if (unit.startsWith("w"))
82
+ totalMs += amount * 7 * 24 * 60 * 60 * 1000;
83
+ else if (unit.startsWith("d"))
84
+ totalMs += amount * 24 * 60 * 60 * 1000;
85
+ else if (unit.startsWith("h"))
86
+ totalMs += amount * 60 * 60 * 1000;
87
+ else if (unit.startsWith("m"))
88
+ totalMs += amount * 60 * 1000;
89
+ else if (unit.startsWith("s"))
90
+ totalMs += amount * 1000;
91
+ }
92
+ match = pattern.exec(input);
93
+ }
94
+ return matched && totalMs > 0 ? totalMs : undefined;
95
+ };
96
+ const parseIsoLikeDate = (candidate) => {
97
+ const normalized = candidate.replace(/\bUTC\b/i, "Z").replace(/\bGMT\b/i, "Z").replace(" ", "T");
98
+ const parsed = Date.parse(normalized);
99
+ return Number.isFinite(parsed) ? parsed : undefined;
100
+ };
101
+ const resolveResetTimestamp = (text, nowMs) => {
102
+ const candidates = [];
103
+ const epochMatch = EPOCH_RESET_PATTERN.exec(text);
104
+ if (epochMatch?.[1]) {
105
+ const raw = Number.parseInt(epochMatch[1], 10);
106
+ if (Number.isFinite(raw) && raw > 0) {
107
+ candidates.push({
108
+ timestampMs: raw > 1000000000000 ? raw : raw * 1000,
109
+ source: "header",
110
+ });
111
+ }
112
+ }
113
+ const shortMatch = SHORT_RESET_PATTERN.exec(text);
114
+ if (shortMatch?.[1]) {
115
+ const seconds = Number.parseInt(shortMatch[1], 10);
116
+ if (Number.isFinite(seconds) && seconds > 0) {
117
+ candidates.push({
118
+ timestampMs: nowMs + seconds * 1000,
119
+ source: "header",
120
+ });
121
+ }
122
+ }
123
+ for (const pattern of RELATIVE_RESET_PATTERNS) {
124
+ const match = pattern.exec(text);
125
+ const durationText = match?.[1];
126
+ if (!durationText)
127
+ continue;
128
+ const durationMs = parseDurationMs(durationText);
129
+ if (durationMs && durationMs > 0) {
130
+ candidates.push({
131
+ timestampMs: nowMs + durationMs,
132
+ source: "relative",
133
+ });
134
+ }
135
+ }
136
+ ISO_LIKE_PATTERN.lastIndex = 0;
137
+ let isoMatch = ISO_LIKE_PATTERN.exec(text);
138
+ while (isoMatch) {
139
+ const ts = parseIsoLikeDate(isoMatch[1] ?? "");
140
+ if (Number.isFinite(ts)) {
141
+ candidates.push({
142
+ timestampMs: ts,
143
+ source: "absolute",
144
+ });
145
+ }
146
+ isoMatch = ISO_LIKE_PATTERN.exec(text);
147
+ }
148
+ const future = candidates.filter((candidate) => Number.isFinite(candidate.timestampMs) && candidate.timestampMs > nowMs);
149
+ if (!future.length)
150
+ return undefined;
151
+ future.sort((left, right) => left.timestampMs - right.timestampMs);
152
+ return future[0];
153
+ };
154
+ const inferWindowTypes = (text) => {
155
+ const set = new Set();
156
+ for (const hint of WINDOW_HINTS) {
157
+ if (hint.pattern.test(text)) {
158
+ set.add(hint.windowType);
159
+ }
160
+ }
161
+ if (!set.size) {
162
+ set.add("other");
163
+ }
164
+ return Array.from(set);
165
+ };
166
+ export const extractUsageLimitErrorText = (error) => {
167
+ const parts = [];
168
+ collectText(error, parts);
169
+ return normalizeSpace(parts.join(" "));
170
+ };
171
+ export const parseUsageLimitError = (error, nowMs = Date.now()) => {
172
+ const message = error instanceof Error ? error.message : String(error ?? "");
173
+ const rawText = extractUsageLimitErrorText(error);
174
+ const messageLower = message.trim().toLowerCase();
175
+ const rawLower = rawText.trim().toLowerCase();
176
+ const text = normalizeSpace(rawText && rawLower.includes(messageLower) ? rawText : `${message} ${rawText}`);
177
+ if (!text)
178
+ return null;
179
+ const looksLikeLimit = LIMIT_PATTERNS.some((pattern) => pattern.test(text));
180
+ if (!looksLikeLimit)
181
+ return null;
182
+ const looksLikeOnlyNonLimitAuth = NON_LIMIT_AUTH_PATTERNS.some((pattern) => pattern.test(text)) &&
183
+ !/(usage[_\s-]*limit|rate[_\s-]*limit|too many requests|\b429\b)/i.test(text);
184
+ if (looksLikeOnlyNonLimitAuth) {
185
+ return null;
186
+ }
187
+ const reset = resolveResetTimestamp(text, nowMs);
188
+ const windowTypes = inferWindowTypes(text);
189
+ return {
190
+ isUsageLimit: true,
191
+ message,
192
+ rawText: text,
193
+ windowTypes,
194
+ resetAt: reset ? new Date(reset.timestampMs).toISOString() : undefined,
195
+ resetAtSource: reset?.source,
196
+ };
197
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcoda/agents",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "Agent registry and capabilities for mcoda.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,8 +30,8 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
- "@mcoda/shared": "0.1.27",
34
- "@mcoda/db": "0.1.27"
33
+ "@mcoda/shared": "0.1.28",
34
+ "@mcoda/db": "0.1.28"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsc -p tsconfig.json",