agent-accelerator 0.4.0 → 0.5.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.
Files changed (46) hide show
  1. package/README.md +149 -57
  2. package/bunfig.toml +1 -0
  3. package/package.json +2 -3
  4. package/src/agent/agent.ts +414 -118
  5. package/src/agent/context.ts +11 -16
  6. package/src/agent/delegation.ts +111 -274
  7. package/src/agent/loop.ts +327 -99
  8. package/src/agent/subagent.ts +4 -12
  9. package/src/agent/worker.ts +435 -0
  10. package/src/index.ts +58 -5
  11. package/src/models/catalog-cache.ts +65 -25
  12. package/src/models/catalog.ts +47 -2
  13. package/src/providers/google.ts +338 -81
  14. package/src/providers/openai-compat.ts +171 -61
  15. package/src/providers/openai.ts +174 -44
  16. package/src/providers/openrouter.ts +129 -44
  17. package/src/providers/shared.ts +169 -17
  18. package/src/providers.ts +375 -11
  19. package/src/scheduler/scheduled-actions.md +41 -0
  20. package/src/scheduler/scheduler.ts +814 -0
  21. package/src/scheduler/store.ts +263 -0
  22. package/src/scheduler/time.ts +148 -0
  23. package/src/scheduler/tool.ts +151 -0
  24. package/src/session/store.ts +229 -113
  25. package/src/streaming/event-stream.ts +6 -4
  26. package/src/telemetry-file.ts +31 -0
  27. package/src/telemetry.ts +65 -0
  28. package/src/tools/executor.ts +163 -38
  29. package/src/tools/subagent-result.ts +60 -0
  30. package/src/types/agent.ts +74 -22
  31. package/src/types/model.ts +39 -0
  32. package/src/types/response.ts +9 -0
  33. package/src/types/scheduler.ts +81 -0
  34. package/src/types/tool.ts +15 -0
  35. package/src/utils/cache.ts +14 -5
  36. package/src/utils/documents.ts +112 -52
  37. package/src/utils/env.ts +21 -7
  38. package/src/utils/errors.ts +26 -1
  39. package/src/utils/headers.ts +15 -15
  40. package/src/utils/home.ts +30 -0
  41. package/src/utils/media.ts +57 -12
  42. package/src/utils/raw-capture.ts +76 -0
  43. package/src/utils/retry.ts +11 -3
  44. package/src/utils/safe-io.ts +252 -0
  45. package/src/utils/session.ts +7 -8
  46. package/src/data/README.md +0 -84
@@ -27,9 +27,11 @@ import type { Message, ContentPart } from "../types/message.ts";
27
27
  import type { TokenUsage, CacheConfig, ServiceTier } from "../types/core.ts";
28
28
  import type { ModelSpec } from "../types/model.ts";
29
29
  import type { AgentResponse } from "../types/response.ts";
30
- import { base64ToBytes } from "../utils/base64.ts";
30
+ import { base64ToBytes, bytesToBase64 } from "../utils/base64.ts";
31
31
  import { getModelFromCatalog } from "../models/catalog.ts";
32
32
  import type { SubAgentStep } from "../types/response.ts";
33
+ import { SessionLoadError } from "../types/errors.ts";
34
+ import { createSessionId } from "../utils/session.ts";
33
35
 
34
36
  /** Cumulative per-session token + cost totals (JSON-safe). */
35
37
  export interface SessionTotals {
@@ -67,6 +69,8 @@ export interface PersistedAgentSession {
67
69
  subagentModel?: string;
68
70
  thinkingLevel?: string;
69
71
  cache?: CacheConfig;
72
+ /** Legacy read-only: pre-compact files duplicated base instructions here;
73
+ * no longer written (`systemPrompt` already embeds them). */
70
74
  instructions?: string;
71
75
  systemPrompt?: string;
72
76
  cachedContentId?: string;
@@ -95,11 +99,13 @@ function specFor(modelStr: string): ReturnType<typeof getModelFromCatalog> {
95
99
  if (raw.includes("/")) {
96
100
  const [prefix, ...rest] = raw.split("/");
97
101
  const id = rest.join("/");
98
- return (
99
- getModelFromCatalog(prefix!, id) ||
100
- getModelFromCatalog(prefix!.toLowerCase(), id) ||
101
- getModelFromCatalog(id, id)
102
- );
102
+ const known = getModelFromCatalog(prefix!, id) || getModelFromCatalog(prefix!.toLowerCase(), id);
103
+ if (known) return known;
104
+ // A bare-id global search is only meaningful for first-class prefixes: a
105
+ // custom endpoint's model must not borrow an unrelated model's price.
106
+ return ["google", "gemini", "openai", "openrouter"].includes(prefix!.toLowerCase())
107
+ ? getModelFromCatalog(id, id)
108
+ : undefined;
103
109
  }
104
110
  return (
105
111
  getModelFromCatalog("google", raw) ||
@@ -307,11 +313,24 @@ function snapshotSubagents(traces: Record<string, PersistedSubAgentTrace> | unde
307
313
  for (const [id, t] of byId) {
308
314
  // Drop assistant step text that merely repeats the final answer: the
309
315
  // step marker (turn/type/timestamp) stays, the full text lives on trace.text.
310
- const steps = (t.steps ?? []).map((s) =>
311
- s.type === "assistant" && typeof s.text === "string" && typeof t.text === "string" && s.text === t.text
312
- ? { turn: s.turn, type: s.type, timestamp: s.timestamp }
313
- : { ...s }
314
- );
316
+ // Reasoning is never dropped: `thinking`/`thoughtSignature` survive the
317
+ // dedup so persisted worker steps keep their reasoning traces.
318
+ const steps = (t.steps ?? []).map((s) => {
319
+ if (
320
+ s.type === "assistant" &&
321
+ typeof s.text === "string" &&
322
+ typeof t.text === "string" &&
323
+ s.text === t.text
324
+ ) {
325
+ const marker: SubAgentStep = { turn: s.turn, type: s.type, timestamp: s.timestamp };
326
+ if (typeof s.thinking === "string" && s.thinking) marker.thinking = s.thinking;
327
+ const sig = (s as { thoughtSignature?: unknown }).thoughtSignature;
328
+ if (typeof sig === "string" && sig) marker.thoughtSignature = sig;
329
+ if (typeof s.isError === "boolean") marker.isError = s.isError;
330
+ return marker;
331
+ }
332
+ return { ...s };
333
+ });
315
334
  lean[id] = { ...t, steps };
316
335
  }
317
336
  try {
@@ -359,6 +378,30 @@ function stripRedundantRawArguments(messages: Message[]): Message[] {
359
378
  });
360
379
  }
361
380
 
381
+ /**
382
+ * Rewrites binary media (`Uint8Array` / `ArrayBuffer`) as `data:` URLs so the
383
+ * snapshot survives JSON (a raw `Uint8Array` would serialize as
384
+ * `{"0":137,"1":80,…}` and corrupt every later turn). Dir mode already moved
385
+ * bytes to `media/` files, so this is a no-op there.
386
+ */
387
+ function inlineBinaryMedia(messages: Message[]): Message[] {
388
+ return messages.map((m) => {
389
+ if (!Array.isArray(m.content)) return m;
390
+ let changed = false;
391
+ const content = (m.content as ContentPart[]).map((part) => {
392
+ const p = part as unknown as Record<string, unknown>;
393
+ if (!p || typeof p.type !== "string" || !(MEDIA_PART_KEYS as readonly string[]).includes(p.type)) return part;
394
+ const value = p[p.type];
395
+ if (!(value instanceof Uint8Array) && !(value instanceof ArrayBuffer)) return part;
396
+ changed = true;
397
+ const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
398
+ const mime = typeof p.mimeType === "string" && p.mimeType ? p.mimeType : "application/octet-stream";
399
+ return { ...p, [p.type]: `data:${mime};base64,${bytesToBase64(bytes)}` } as unknown as ContentPart;
400
+ });
401
+ return changed ? { ...m, content } : m;
402
+ });
403
+ }
404
+
362
405
  /** Builds a storable snapshot from a live agent + optional telemetry. */
363
406
  export function buildSessionData(agent: SessionAgentLike, telemetry?: SessionTelemetry | SessionTotals | null): PersistedAgentSession {
364
407
  const rawModel = agent.modelStringOrSpec as any;
@@ -372,35 +415,109 @@ export function buildSessionData(agent: SessionAgentLike, telemetry?: SessionTel
372
415
  const parentSessionId =
373
416
  typeof (agent as any)?.parentSessionId === "string" ? (agent as any).parentSessionId as string : undefined;
374
417
  const subagents = snapshotSubagents((agent as any)?.subagentTraces as Record<string, PersistedSubAgentTrace> | undefined);
375
- const messages = stripRedundantRawArguments(agent.context.messages);
418
+ const messages = inlineBinaryMedia(stripRedundantRawArguments(agent.context.messages));
419
+ // `cache.sessionId` echoes the top-level `sessionId` (affinity alias): drop
420
+ // it from the file when identical so it isn't stored twice. Readers still
421
+ // accept legacy files carrying it (`importSession`, loop fallbacks).
422
+ const cacheForFile = (() => {
423
+ if (!agent.cacheConfig) return undefined;
424
+ if (agent.cacheConfig.sessionId === agent.sessionId) {
425
+ const { sessionId: _dropped, ...rest } = agent.cacheConfig;
426
+ return Object.keys(rest).length > 0 ? rest : undefined;
427
+ }
428
+ return agent.cacheConfig;
429
+ })();
430
+ // Field order is the on-disk layout contract: identity → config →
431
+ // systemPrompt → totals → messages → lineage/traces. `instructions` is
432
+ // intentionally never written: `systemPrompt` already embeds it (plus the
433
+ // appended directives), so writing both duplicates the same data.
434
+ // Readers still accept legacy files carrying `instructions`.
376
435
  return {
377
436
  version: 1,
378
437
  sessionId: agent.sessionId,
379
438
  model,
380
439
  ...(subagentModel ? { subagentModel: String(subagentModel) } : {}),
381
440
  ...(agent.thinkingConfig?.level ? { thinkingLevel: agent.thinkingConfig.level as string } : {}),
382
- ...(agent.cacheConfig ? { cache: agent.cacheConfig } : {}),
383
- ...(agent.instructions ? { instructions: agent.instructions } : {}),
441
+ ...(cacheForFile ? { cache: cacheForFile } : {}),
384
442
  ...(agent.context.systemPrompt ? { systemPrompt: agent.context.systemPrompt } : {}),
385
443
  ...(agent.context.cachedContentId ? { cachedContentId: agent.context.cachedContentId } : {}),
386
- messages,
387
444
  ...(totals ? { totals } : {}),
445
+ messages,
388
446
  ...(parentSessionId ? { parentSessionId } : {}),
389
447
  ...(subagents ? { subagents } : {}),
390
448
  };
391
449
  }
392
450
 
393
- /** Serializes a snapshot to a pretty-printed `.json` document (2-space indent). */
451
+ /**
452
+ * Serializes a snapshot so every top-level field occupies exactly one line
453
+ * (` "key": <compact-json>`), except `messages`: the conversation array
454
+ * expands with one line per turn (` {…}`), so each turn stays readable
455
+ * without exploding nested values vertically. The file stays valid JSON and
456
+ * greppable. Field order follows `buildSessionData` (totals before messages).
457
+ */
394
458
  export function serializeSession(data: PersistedAgentSession): string {
395
- return JSON.stringify({ ...data, version: 1 }, null, 2);
459
+ const full = { ...data, version: 1 } as Record<string, unknown>;
460
+ const keys = Object.keys(full).filter((k) => full[k] !== undefined);
461
+ const lines = ["{"];
462
+ keys.forEach((k, i) => {
463
+ const comma = i < keys.length - 1 ? "," : "";
464
+ if (k === "messages" && Array.isArray(full[k])) {
465
+ const turns = full[k] as unknown[];
466
+ if (turns.length === 0) {
467
+ lines.push(` ${JSON.stringify(k)}: []${comma}`);
468
+ } else {
469
+ lines.push(` ${JSON.stringify(k)}: [`);
470
+ turns.forEach((t, j) => {
471
+ lines.push(` ${JSON.stringify(t)}${j < turns.length - 1 ? "," : ""}`);
472
+ });
473
+ lines.push(` ]${comma}`);
474
+ }
475
+ return;
476
+ }
477
+ lines.push(` ${JSON.stringify(k)}: ${JSON.stringify(full[k])}${comma}`);
478
+ });
479
+ lines.push("}");
480
+ return lines.join("\n");
396
481
  }
397
482
 
398
- /** Parses new pretty `.session.json` snapshots AND legacy `.session.jsonl` documents. */
399
- export function deserializeSession(raw: string): PersistedAgentSession | null {
483
+ const MESSAGE_ROLES = new Set(["system", "user", "assistant", "tool"]);
484
+
485
+ /**
486
+ * Index of the first structurally invalid message, or -1. A loaded snapshot
487
+ * is untrusted input: provider history builders assume `role` + `content`
488
+ * shapes, so a hand-edited or tampered file must fail at load, not as a
489
+ * TypeError deep inside a request.
490
+ */
491
+ export function findInvalidMessage(messages: unknown): number {
492
+ if (!Array.isArray(messages)) return 0;
493
+ for (let i = 0; i < messages.length; i++) {
494
+ const m = messages[i] as { role?: unknown; content?: unknown } | null;
495
+ if (!m || typeof m !== "object" || typeof m.role !== "string" || !MESSAGE_ROLES.has(m.role)) return i;
496
+ if (typeof m.content === "string") continue;
497
+ if (!Array.isArray(m.content)) return i;
498
+ for (const part of m.content) {
499
+ if (!part || typeof part !== "object" || typeof (part as { type?: unknown }).type !== "string") return i;
500
+ }
501
+ }
502
+ return -1;
503
+ }
504
+
505
+ /** Load limits for session snapshots (see {@link MAX_SESSION_MESSAGES} / {@link MAX_SESSION_JSON_BYTES}). */
506
+ export interface SessionLimits {
507
+ maxMessages?: number;
508
+ maxBytes?: number;
509
+ }
510
+
511
+ /** Marker for a structurally valid payload that exceeds the configured limits. */
512
+ const OVER_LIMIT = Symbol("session-over-limit");
513
+
514
+ function parseSession(raw: string, limits?: SessionLimits): PersistedAgentSession | null | typeof OVER_LIMIT {
515
+ const maxMessages = limits?.maxMessages ?? MAX_SESSION_MESSAGES;
516
+ const maxBytes = limits?.maxBytes ?? MAX_SESSION_JSON_BYTES;
400
517
  const text = (raw ?? "").trim();
401
518
  if (!text) return null;
402
- // Q-10: reject oversized payloads (>10MB JSON) — return null, no console.
403
- if (text.length > MAX_SESSION_JSON_BYTES) return null;
519
+ // Q-10: oversized payloads are rejected, but flagged as over-limit (NOT corrupt).
520
+ if (text.length > maxBytes) return OVER_LIMIT;
404
521
  try {
405
522
  // Legacy JSONL: lines shaped {type: session|mainModel|subagentModel|metrics|message|...}
406
523
  if (text.startsWith('{"type"')) {
@@ -453,11 +570,11 @@ export function deserializeSession(raw: string): PersistedAgentSession | null {
453
570
  }
454
571
  }
455
572
  if (!sessionId && messages.length === 0) return null;
456
- // Q-10: oversize guard — return null, no console.
457
- if (messages.length > MAX_SESSION_MESSAGES) return null;
573
+ // Q-10: oversize guard — flagged as over-limit, not corrupt.
574
+ if (messages.length > maxMessages) return OVER_LIMIT;
458
575
  return {
459
576
  version: 1,
460
- sessionId: sessionId || `accel-${Date.now()}`,
577
+ sessionId: sessionId || createSessionId(),
461
578
  model,
462
579
  ...(subagentModel ? { subagentModel } : {}),
463
580
  ...(thinkingLevel ? { thinkingLevel } : {}),
@@ -478,10 +595,10 @@ export function deserializeSession(raw: string): PersistedAgentSession | null {
478
595
  if ("version" in obj && (obj as { version?: unknown }).version !== 1) return null;
479
596
  // Very old single-JSON shape without version: {sessionId, model, context:{messages}}
480
597
  if (obj.context && Array.isArray(obj.context.messages) && !Array.isArray(obj.messages)) {
481
- if ((obj.context.messages as unknown[]).length > MAX_SESSION_MESSAGES) return null;
598
+ if ((obj.context.messages as unknown[]).length > maxMessages) return OVER_LIMIT;
482
599
  return {
483
600
  version: 1,
484
- sessionId: obj.sessionId ?? obj.id ?? `accel-${Date.now()}`,
601
+ sessionId: obj.sessionId ?? obj.id ?? createSessionId(),
485
602
  model: obj.model ?? "",
486
603
  subagentModel: obj.subagentModel,
487
604
  thinkingLevel: obj.thinkingLevel ?? obj.thinkingLevelChange,
@@ -496,12 +613,12 @@ export function deserializeSession(raw: string): PersistedAgentSession | null {
496
613
  };
497
614
  }
498
615
  if (!obj.sessionId && !Array.isArray(obj.messages)) return null;
499
- if (Array.isArray(obj.messages) && (obj.messages as unknown[]).length > MAX_SESSION_MESSAGES) return null;
616
+ if (Array.isArray(obj.messages) && (obj.messages as unknown[]).length > maxMessages) return OVER_LIMIT;
500
617
  if ("sessionId" in obj && obj.sessionId !== undefined && typeof obj.sessionId !== "string") return null;
501
618
  if ("messages" in obj && obj.messages !== undefined && !Array.isArray(obj.messages)) return null;
502
619
  return {
503
620
  version: 1,
504
- sessionId: obj.sessionId ?? `accel-${Date.now()}`,
621
+ sessionId: obj.sessionId ?? createSessionId(),
505
622
  model: obj.model ?? "",
506
623
  subagentModel: obj.subagentModel,
507
624
  thinkingLevel: obj.thinkingLevel,
@@ -519,15 +636,59 @@ export function deserializeSession(raw: string): PersistedAgentSession | null {
519
636
  }
520
637
  }
521
638
 
639
+ /** Parses new pretty `.session.json` snapshots AND legacy `.session.jsonl` documents. Returns null for empty, corrupt or over-limit input. */
640
+ export function deserializeSession(raw: string, limits?: SessionLimits): PersistedAgentSession | null {
641
+ const parsed = parseSession(raw, limits);
642
+ if (parsed === OVER_LIMIT || parsed === null) return null;
643
+ return findInvalidMessage(parsed.messages) === -1 ? parsed : null;
644
+ }
645
+
646
+ /** Outcome of {@link inspectSessionPayload}. */
647
+ export type SessionPayloadStatus = "ok" | "empty" | "corrupt" | "over_limit";
648
+
649
+ /**
650
+ * Like {@link deserializeSession} but says WHY a payload did not load. A
651
+ * valid snapshot that merely exceeds the load limits is `over_limit`, never
652
+ * `corrupt`, so persistence never quarantines (or loops on) healthy data.
653
+ */
654
+ export function inspectSessionPayload(
655
+ raw: string,
656
+ limits?: SessionLimits
657
+ ): { status: SessionPayloadStatus; session?: PersistedAgentSession } {
658
+ if (!(raw ?? "").trim()) return { status: "empty" };
659
+ const parsed = parseSession(raw, limits);
660
+ if (parsed === OVER_LIMIT) return { status: "over_limit" };
661
+ if (parsed === null || findInvalidMessage(parsed.messages) !== -1) return { status: "corrupt" };
662
+ return { status: "ok", session: parsed };
663
+ }
664
+
522
665
  /** Loads a session file (pretty `.json` or legacy `.jsonl`). Returns null when missing/corrupt. */
523
- export function loadSessionFile(filePath: string): PersistedAgentSession | null {
666
+ export function loadSessionFile(filePath: string, limits?: SessionLimits): PersistedAgentSession | null {
667
+ const resolved = path.resolve(filePath);
668
+ let raw: string;
524
669
  try {
525
- const resolved = path.resolve(filePath);
526
670
  if (!fs.existsSync(resolved)) return null;
527
- return deserializeSession(fs.readFileSync(resolved, "utf8"));
671
+ raw = fs.readFileSync(resolved, "utf8");
528
672
  } catch {
529
673
  return null;
530
674
  }
675
+ return sessionFromPayload(raw, resolved, limits);
676
+ }
677
+
678
+ /**
679
+ * Shared load tail: corrupt/empty -> null (callers start fresh), but a valid
680
+ * over-limit snapshot throws so the app cannot silently start blank and then
681
+ * overwrite a healthy long-running session.
682
+ */
683
+ function sessionFromPayload(raw: string, file: string, limits?: SessionLimits): PersistedAgentSession | null {
684
+ const res = inspectSessionPayload(raw, limits);
685
+ if (res.status === "over_limit") {
686
+ throw new SessionLoadError(
687
+ `[Agent Accelerator] Session "${file}" exceeds the load limits (maxMessages ${limits?.maxMessages ?? MAX_SESSION_MESSAGES}, maxBytes ${limits?.maxBytes ?? MAX_SESSION_JSON_BYTES}). Raise them via the limits argument or archive the file; it was NOT modified.`,
688
+ { file }
689
+ );
690
+ }
691
+ return res.session ?? null;
531
692
  }
532
693
 
533
694
  /** Atomic file write (temp + rename) so a crash never leaves half-written JSON. */
@@ -570,7 +731,9 @@ export function saveSessionFile(
570
731
  if (fs.existsSync(resolved)) {
571
732
  try {
572
733
  const raw = fs.readFileSync(resolved, "utf8");
573
- if (raw.trim().length > 0 && deserializeSession(raw) === null) {
734
+ // Only genuinely corrupt payloads are quarantined; a healthy snapshot
735
+ // over the load limits is simply rewritten (it is our own prior save).
736
+ if (inspectSessionPayload(raw).status === "corrupt") {
574
737
  try { fs.renameSync(resolved, `${resolved}.corrupt-${Date.now()}`); } catch {}
575
738
  }
576
739
  } catch {}
@@ -790,7 +953,7 @@ export function saveSessionDir(
790
953
  if (fs.existsSync(target)) {
791
954
  try {
792
955
  const raw = fs.readFileSync(target, "utf8");
793
- if (raw.trim().length > 0 && deserializeSession(raw) === null) {
956
+ if (inspectSessionPayload(raw).status === "corrupt") {
794
957
  try { fs.renameSync(target, `${target}.corrupt-${Date.now()}`); } catch {}
795
958
  }
796
959
  } catch {}
@@ -814,14 +977,14 @@ export interface LoadedSessionDir {
814
977
  * single files). Relative `media/…` refs resolve against the folder.
815
978
  * Returns null when nothing loadable exists.
816
979
  */
817
- export function loadSessionDir(sessionPath: string): LoadedSessionDir | null {
980
+ export function loadSessionDir(sessionPath: string, limits?: SessionLimits): LoadedSessionDir | null {
818
981
  try {
819
982
  const resolved = path.resolve(sessionPath);
820
983
  let dir = resolved;
821
984
  try {
822
985
  const st = fs.statSync(resolved);
823
986
  if (st.isFile()) {
824
- const single = loadSessionFile(resolved);
987
+ const single = loadSessionFile(resolved, limits);
825
988
  if (!single) return null;
826
989
  dir = path.dirname(resolved);
827
990
  if (single.messages) {
@@ -834,11 +997,12 @@ export function loadSessionDir(sessionPath: string): LoadedSessionDir | null {
834
997
  }
835
998
  const file = path.join(dir, "session.json");
836
999
  if (!fs.existsSync(file)) return null;
837
- const data = deserializeSession(fs.readFileSync(file, "utf8"));
1000
+ const data = sessionFromPayload(fs.readFileSync(file, "utf8"), file, limits);
838
1001
  if (!data) return null;
839
1002
  data.messages = resolveMediaPaths(data.messages, dir);
840
1003
  return { session: data, dir };
841
- } catch {
1004
+ } catch (err) {
1005
+ if (err instanceof SessionLoadError) throw err;
842
1006
  return null;
843
1007
  }
844
1008
  }
@@ -870,10 +1034,10 @@ export function findLatestSessionDir(rootDir: string): string | null {
870
1034
  // size/version guards, no behavior change otherwise).
871
1035
  // ---------------------------------------------------------------------------
872
1036
 
873
- /** Max messages accepted in a session snapshot (Q-10). */
874
- export const MAX_SESSION_MESSAGES = 5000;
875
- /** Max raw JSON bytes accepted in deserializeSession (Q-10). */
876
- export const MAX_SESSION_JSON_BYTES = 10 * 1024 * 1024;
1037
+ /** Default max messages accepted when loading a session snapshot (override via `SessionLimits`). */
1038
+ export const MAX_SESSION_MESSAGES = 50_000;
1039
+ /** Default max raw JSON size accepted when loading a session snapshot (override via `SessionLimits`). */
1040
+ export const MAX_SESSION_JSON_BYTES = 100 * 1024 * 1024;
877
1041
 
878
1042
  function sessionLoadError(message: string): Error {
879
1043
  const e = new Error(`[Agent Accelerator] Invalid session: ${message}`);
@@ -936,70 +1100,13 @@ export function getSessionContextWindowOrUndefined(modelStr: string): number | u
936
1100
  }
937
1101
 
938
1102
  // ---------------------------------------------------------------------------
939
- // Q-41: telemetry bus (no console.* in library code).
1103
+ // Q-41: telemetry bus lives in `../telemetry.ts` (dependency-free, so low-level
1104
+ // modules such as the catalog cache and event stream can emit without import
1105
+ // cycles). Re-exported here for backward compatibility.
940
1106
  // ---------------------------------------------------------------------------
941
-
942
- /** Telemetry event emitted via the in-process bus (Q-41). */
943
- export interface TelemetryEvent {
944
- code: string;
945
- message: string;
946
- level?: "info" | "warn" | "error";
947
- data?: unknown;
948
- timestamp: number;
949
- }
950
-
951
- /** Listener subscribed via `telemetryBus.onEvent`. */
952
- export type TelemetryListener = (event: TelemetryEvent) => void;
953
-
954
- interface TelemetryBus {
955
- /** Subscribes to telemetry events. Returns an unsubscribe function. Default: no-op delivery (no listeners, no console). */
956
- onEvent(listener: TelemetryListener): () => void;
957
- /** Emits a warning (level `warn`). Never throws, never logs to console. */
958
- emitWarning(code: string, message: string, data?: unknown): void;
959
- /** Emits a generic event. Never throws, never logs to console. */
960
- emit(event: Omit<TelemetryEvent, "timestamp"> & { timestamp?: number }): void;
961
- /** Removes all listeners (useful in tests). */
962
- clear(): void;
963
- }
964
-
965
- /**
966
- * In-process telemetry bus (Q-41). Library code routes warnings/anomalies
967
- * here instead of `console.*`. Default has zero listeners so emission is a
968
- * no-op; CLIs/tests attach a pretty logger via `onEvent`.
969
- */
970
- export const telemetryBus: TelemetryBus = (() => {
971
- const listeners = new Set<TelemetryListener>();
972
- const deliver = (event: TelemetryEvent): void => {
973
- for (const l of [...listeners]) {
974
- try { l(event); } catch {
975
- // why: a faulty telemetry listener must not break the emitting run.
976
- }
977
- }
978
- };
979
- return {
980
- onEvent(listener: TelemetryListener): () => void {
981
- listeners.add(listener);
982
- return () => { listeners.delete(listener); };
983
- },
984
- emitWarning(code: string, message: string, data?: unknown): void {
985
- try {
986
- deliver({ code, message, level: "warn", data, timestamp: Date.now() });
987
- } catch {
988
- // why: telemetry emission is observability-only.
989
- }
990
- },
991
- emit(event: Omit<TelemetryEvent, "timestamp"> & { timestamp?: number }): void {
992
- try {
993
- deliver({ level: "info", ...event, timestamp: event.timestamp ?? Date.now() });
994
- } catch {
995
- // why: telemetry emission is observability-only.
996
- }
997
- },
998
- clear(): void {
999
- listeners.clear();
1000
- },
1001
- };
1002
- })();
1107
+ import { telemetryBus } from "../telemetry.ts";
1108
+ export { telemetryBus };
1109
+ export type { TelemetryEvent, TelemetryListener } from "../telemetry.ts";
1003
1110
 
1004
1111
  /** Reports a usage/cost anomaly via the global hook + telemetry bus (Q-35). Never logs. */
1005
1112
  export function reportUsageAnomaly(detail: { type: string; [k: string]: unknown }): void {
@@ -1098,19 +1205,28 @@ export type PricedCost = NonNullable<TokenUsage["cost"]> & {
1098
1205
  };
1099
1206
 
1100
1207
  /**
1101
- * Service-tier price multipliers (Q-36). ESTIMATES, not vendor guarantees:
1102
- * `flex` batches trade latency for ~0.5x price, `priority` reserves capacity
1103
- * at ~1.5x. Standard/default is 1.0. Documented as estimates because vendors
1104
- * do not publish exact tier multipliers in the catalog.
1208
+ * Service-tier price multipliers (Q-36). Aligned with pi
1209
+ * (`api/openai-responses.ts:387`): `flex` trades latency for ~0.5x price,
1210
+ * `priority`/`fast` reserve capacity at ~2x (`2.5x` for `gpt-5.5`).
1211
+ * Standard/default is 1.0. Estimates because vendors do not publish exact
1212
+ * tier multipliers in the catalog.
1105
1213
  */
1106
1214
  const SERVICE_TIER_MULTIPLIERS: Record<string, number> = {
1107
1215
  flex: 0.5,
1108
- priority: 1.5,
1216
+ priority: 2,
1217
+ fast: 2,
1109
1218
  };
1110
1219
 
1111
- function serviceTierMultiplier(serviceTier?: string): { multiplier: number; tier?: string } {
1220
+ function serviceTierMultiplier(serviceTier?: string, modelId?: string): { multiplier: number; tier?: string } {
1112
1221
  if (!serviceTier) return { multiplier: 1 };
1113
- const m = SERVICE_TIER_MULTIPLIERS[serviceTier.toLowerCase()];
1222
+ const lower = serviceTier.toLowerCase();
1223
+ if (lower === "priority" || lower === "fast") {
1224
+ if (typeof modelId === "string" && modelId.includes("gpt-5.5")) {
1225
+ return { multiplier: 2.5, tier: serviceTier };
1226
+ }
1227
+ return { multiplier: 2, tier: serviceTier };
1228
+ }
1229
+ const m = SERVICE_TIER_MULTIPLIERS[lower];
1114
1230
  return m !== undefined ? { multiplier: m, tier: serviceTier } : { multiplier: 1, tier: serviceTier };
1115
1231
  }
1116
1232
 
@@ -1190,7 +1306,7 @@ function resolveEffectivePricing(
1190
1306
  export function priceUsage(
1191
1307
  usage: TokenUsage,
1192
1308
  spec?: ModelSpec,
1193
- opts?: { serviceTier?: ServiceTier | string }
1309
+ opts?: { serviceTier?: ServiceTier | string; modelId?: string }
1194
1310
  ): PricedCost | undefined {
1195
1311
  const reported = usage?.cost;
1196
1312
  if (reported && typeof reported.totalCost === "number" && reported.totalCost > 0) {
@@ -1233,7 +1349,7 @@ export function priceUsage(
1233
1349
  if (!pricing.input && !pricing.output && !pricing.cacheRead && !pricing.cacheWrite && !pricing.audio) {
1234
1350
  return undefined;
1235
1351
  }
1236
- const { multiplier, tier: serviceTier } = serviceTierMultiplier(opts?.serviceTier);
1352
+ const { multiplier, tier: serviceTier } = serviceTierMultiplier(opts?.serviceTier, opts?.modelId ?? spec?.id);
1237
1353
  const nonCachedInput = Math.max(0, inputTokens - cachedTokens);
1238
1354
  // Integer micro-USD accumulation: microUSD = tokens * pricePerMillion
1239
1355
  // ($P per 1M tokens == $P/1e6 per token == P micro-USD per token).
@@ -1,4 +1,5 @@
1
1
  import type { StreamEvent, AgentResponse } from "../types/response.ts";
2
+ import { telemetryBus } from "../telemetry.ts";
2
3
 
3
4
  /** Listener invoked for a matching stream event type or `*`. */
4
5
  export type StreamEventListener = (event: StreamEvent) => void;
@@ -36,9 +37,10 @@ function approxEventBytes(event: StreamEvent): number {
36
37
  return n;
37
38
  }
38
39
 
39
- function reportStreamAnomaly(detail: unknown): void {
40
- // why: telemetry hook only, never console — streams are hot paths and
41
- // logging per-drop would spam and break no-console policy (Q-29/Q-41).
40
+ function reportStreamAnomaly(detail: { type: string; [k: string]: unknown }): void {
41
+ // why: bus + hook only, never console — streams are hot paths and logging
42
+ // per-drop would spam and break no-console policy (Q-29/Q-41).
43
+ telemetryBus.emitWarning(detail.type, `[Agent Accelerator] stream anomaly: ${detail.type}`, detail);
42
44
  try {
43
45
  (globalThis as unknown as { __agentAccelAnomaly?: (d: unknown) => void }).__agentAccelAnomaly?.(detail);
44
46
  } catch {
@@ -256,7 +258,7 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
256
258
  this.cancelHandlers.clear();
257
259
  const err = reason instanceof Error ? reason : null;
258
260
  const abortErr: Error =
259
- err && (err.name === "AbortError" || /abort|cancel/i.test(err.message))
261
+ err && err.name === "AbortError"
260
262
  ? err
261
263
  : Object.assign(new Error("Stream aborted"), { name: "AbortError" });
262
264
  this.fail(abortErr);
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Opt-in disk sink for the telemetry bus (`AgentConfig.telemetry.persist`).
3
+ * Appends one JSON line per event to `<home>/telemetry/<YYYY-MM-DD>.ndjson`.
4
+ * Each event is a single synchronous append, so a process crash keeps every
5
+ * event already emitted: a `tool.call_started` with no ending event was in
6
+ * flight, and the last `tool.progress` shows how far it got. Power loss is not
7
+ * covered (no fsync). `turn.thinking` (full reasoning text) is never written.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { telemetryBus } from "./telemetry.ts";
12
+ import { safeStringify } from "./utils/serialization.ts";
13
+
14
+ const sinks = new Set<string>();
15
+
16
+ /** Starts appending bus events under `home` (idempotent per folder). */
17
+ export function persistTelemetry(home: string): void {
18
+ const dir = path.join(home, "telemetry");
19
+ if (sinks.has(dir)) return;
20
+ sinks.add(dir);
21
+ telemetryBus.onEvent((event) => {
22
+ if (event.code === "turn.thinking") return;
23
+ try {
24
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
25
+ const file = path.join(dir, `${new Date(event.timestamp).toISOString().slice(0, 10)}.ndjson`);
26
+ fs.appendFileSync(file, safeStringify(event) + "\n", { mode: 0o600 });
27
+ } catch {
28
+ // why: a full disk must never fail a run, and reporting it would re-enter this listener.
29
+ }
30
+ });
31
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * In-process telemetry bus (Q-41). Library code routes warnings, anomalies and
3
+ * per-turn traces here instead of `console.*`. The default has zero listeners,
4
+ * so emission is a no-op; CLIs, tests and observability layers attach with
5
+ * `telemetryBus.onEvent`. Intentionally dependency-free: every module in the
6
+ * SDK (providers, catalog cache, event stream, loop, session store) may import
7
+ * it without creating an import cycle.
8
+ */
9
+
10
+ /** Telemetry event emitted via the in-process bus (Q-41). */
11
+ export interface TelemetryEvent {
12
+ code: string;
13
+ message: string;
14
+ level?: "info" | "warn" | "error";
15
+ data?: unknown;
16
+ timestamp: number;
17
+ }
18
+
19
+ /** Listener subscribed via `telemetryBus.onEvent`. */
20
+ export type TelemetryListener = (event: TelemetryEvent) => void;
21
+
22
+ interface TelemetryBus {
23
+ /** Subscribes to telemetry events. Returns an unsubscribe function. Default: no-op delivery (no listeners, no console). */
24
+ onEvent(listener: TelemetryListener): () => void;
25
+ /** Emits a warning (level `warn`). Never throws, never logs to console. */
26
+ emitWarning(code: string, message: string, data?: unknown): void;
27
+ /** Emits a generic event. Never throws, never logs to console. */
28
+ emit(event: Omit<TelemetryEvent, "timestamp"> & { timestamp?: number }): void;
29
+ /** Removes all listeners (useful in tests). */
30
+ clear(): void;
31
+ }
32
+
33
+ export const telemetryBus: TelemetryBus = (() => {
34
+ const listeners = new Set<TelemetryListener>();
35
+ const deliver = (event: TelemetryEvent): void => {
36
+ for (const l of [...listeners]) {
37
+ try { l(event); } catch {
38
+ // why: a faulty telemetry listener must not break the emitting run.
39
+ }
40
+ }
41
+ };
42
+ return {
43
+ onEvent(listener: TelemetryListener): () => void {
44
+ listeners.add(listener);
45
+ return () => { listeners.delete(listener); };
46
+ },
47
+ emitWarning(code: string, message: string, data?: unknown): void {
48
+ try {
49
+ deliver({ code, message, level: "warn", data, timestamp: Date.now() });
50
+ } catch {
51
+ // why: telemetry emission is observability-only.
52
+ }
53
+ },
54
+ emit(event: Omit<TelemetryEvent, "timestamp"> & { timestamp?: number }): void {
55
+ try {
56
+ deliver({ level: "info", ...event, timestamp: event.timestamp ?? Date.now() });
57
+ } catch {
58
+ // why: telemetry emission is observability-only.
59
+ }
60
+ },
61
+ clear(): void {
62
+ listeners.clear();
63
+ },
64
+ };
65
+ })();