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.
- package/README.md +149 -57
- package/bunfig.toml +1 -0
- package/package.json +2 -3
- package/src/agent/agent.ts +414 -118
- package/src/agent/context.ts +11 -16
- package/src/agent/delegation.ts +111 -274
- package/src/agent/loop.ts +327 -99
- package/src/agent/subagent.ts +4 -12
- package/src/agent/worker.ts +435 -0
- package/src/index.ts +58 -5
- package/src/models/catalog-cache.ts +65 -25
- package/src/models/catalog.ts +47 -2
- package/src/providers/google.ts +338 -81
- package/src/providers/openai-compat.ts +171 -61
- package/src/providers/openai.ts +174 -44
- package/src/providers/openrouter.ts +129 -44
- package/src/providers/shared.ts +169 -17
- package/src/providers.ts +375 -11
- package/src/scheduler/scheduled-actions.md +41 -0
- package/src/scheduler/scheduler.ts +814 -0
- package/src/scheduler/store.ts +263 -0
- package/src/scheduler/time.ts +148 -0
- package/src/scheduler/tool.ts +151 -0
- package/src/session/store.ts +229 -113
- package/src/streaming/event-stream.ts +6 -4
- package/src/telemetry-file.ts +31 -0
- package/src/telemetry.ts +65 -0
- package/src/tools/executor.ts +163 -38
- package/src/tools/subagent-result.ts +60 -0
- package/src/types/agent.ts +74 -22
- package/src/types/model.ts +39 -0
- package/src/types/response.ts +9 -0
- package/src/types/scheduler.ts +81 -0
- package/src/types/tool.ts +15 -0
- package/src/utils/cache.ts +14 -5
- package/src/utils/documents.ts +112 -52
- package/src/utils/env.ts +21 -7
- package/src/utils/errors.ts +26 -1
- package/src/utils/headers.ts +15 -15
- package/src/utils/home.ts +30 -0
- package/src/utils/media.ts +57 -12
- package/src/utils/raw-capture.ts +76 -0
- package/src/utils/retry.ts +11 -3
- package/src/utils/safe-io.ts +252 -0
- package/src/utils/session.ts +7 -8
- package/src/data/README.md +0 -84
package/src/session/store.ts
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
...(
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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:
|
|
403
|
-
if (text.length >
|
|
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 —
|
|
457
|
-
if (messages.length >
|
|
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 ||
|
|
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 >
|
|
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 ??
|
|
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 >
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
/**
|
|
874
|
-
export const MAX_SESSION_MESSAGES =
|
|
875
|
-
/**
|
|
876
|
-
export const MAX_SESSION_JSON_BYTES =
|
|
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
|
|
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
|
-
|
|
943
|
-
export
|
|
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).
|
|
1102
|
-
* `flex`
|
|
1103
|
-
* at ~
|
|
1104
|
-
*
|
|
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:
|
|
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
|
|
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:
|
|
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 &&
|
|
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
|
+
}
|
package/src/telemetry.ts
ADDED
|
@@ -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
|
+
})();
|