agent-accelerator 0.5.6 → 0.5.8

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 (57) hide show
  1. package/README.md +10 -17
  2. package/package.json +1 -1
  3. package/src/agent/agent.ts +92 -57
  4. package/src/agent/architecture.md +25 -0
  5. package/src/agent/context.ts +7 -3
  6. package/src/agent/delegation.ts +30 -96
  7. package/src/agent/loop.ts +54 -56
  8. package/src/agent/subagent.ts +4 -4
  9. package/src/agent/worker.ts +1 -3
  10. package/src/architecture.md +28 -0
  11. package/src/index.ts +13 -80
  12. package/src/models/architecture.md +14 -0
  13. package/src/models/catalog-cache.ts +10 -7
  14. package/src/models/catalog.ts +20 -13
  15. package/src/providers/architecture.md +24 -0
  16. package/src/providers/google.ts +24 -4
  17. package/src/providers/openai-compat.ts +29 -7
  18. package/src/providers/openai.ts +22 -6
  19. package/src/providers/openrouter.ts +14 -2
  20. package/src/providers/registry.ts +1 -0
  21. package/src/providers/shared.ts +4 -4
  22. package/src/providers.ts +9 -53
  23. package/src/scheduler/architecture.md +22 -0
  24. package/src/scheduler/scheduler.ts +131 -56
  25. package/src/scheduler/store.ts +274 -54
  26. package/src/scheduler/time.ts +2 -0
  27. package/src/session/architecture.md +19 -0
  28. package/src/session/store.ts +37 -22
  29. package/src/session/usage.ts +1 -0
  30. package/src/streaming/architecture.md +13 -0
  31. package/src/streaming/event-stream.ts +16 -11
  32. package/src/telemetry-file.ts +74 -24
  33. package/src/telemetry.ts +3 -2
  34. package/src/tools/architecture.md +19 -0
  35. package/src/tools/executor.ts +14 -1
  36. package/src/tools/schema.ts +1 -0
  37. package/src/tools/subagent-result.ts +1 -0
  38. package/src/tools/tool.ts +8 -8
  39. package/src/types/agent.ts +22 -20
  40. package/src/types/architecture.md +20 -0
  41. package/src/types/response.ts +8 -8
  42. package/src/types/scheduler.ts +2 -2
  43. package/src/types/tool.ts +5 -5
  44. package/src/update-models.ts +2 -2
  45. package/src/utils/architecture.md +25 -0
  46. package/src/utils/base64.ts +2 -0
  47. package/src/utils/cache.ts +6 -4
  48. package/src/utils/durable.ts +78 -3
  49. package/src/utils/env.ts +1 -1
  50. package/src/utils/errors.ts +4 -0
  51. package/src/utils/headers.ts +7 -4
  52. package/src/utils/home.ts +1 -1
  53. package/src/utils/media.ts +1 -0
  54. package/src/utils/raw-capture.ts +3 -0
  55. package/src/utils/retry.ts +2 -1
  56. package/src/utils/safe-io.ts +4 -0
  57. package/src/utils/session.ts +10 -166
@@ -127,10 +127,12 @@ export interface PersistedAgentSession {
127
127
  tools?: SessionToolRecord[];
128
128
  }
129
129
 
130
+ /** Zeroed cumulative session totals; in: SessionTelemetry, out: a SessionTotals. */
130
131
  export function emptyTotals(): SessionTotals {
131
132
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0 };
132
133
  }
133
134
 
135
+ // A finite non-negative number, else 0; in: telemetry and formatting, out: a safe number.
134
136
  function toNumber(n: unknown): number {
135
137
  const v = typeof n === "number" ? n : Number(n);
136
138
  return Number.isFinite(v) && v >= 0 ? v : 0;
@@ -164,7 +166,7 @@ function specFor(modelStr: string): ReturnType<typeof getModelFromCatalog> {
164
166
  }
165
167
 
166
168
  /** Context window for telemetry display. Falls back to 1M like the chat CLI.
167
- * @deprecated (Q-34) Kept for compat; new code should use
169
+ * @deprecated Kept for compat; new code should use
168
170
  * `getSessionContextWindowOrUndefined` and handle `undefined` explicitly.
169
171
  */
170
172
  export function getSessionContextWindow(modelStr: string): number {
@@ -191,6 +193,7 @@ export function computeSessionTurnCost(usage: TokenUsage, modelStr: string): num
191
193
  }
192
194
  }
193
195
 
196
+ /** Compact token count (`1.2k`, `3.4M`); in: the status bar, out: a string. */
194
197
  export function formatSessionTokens(n: number): string {
195
198
  const v = toNumber(n);
196
199
  if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
@@ -198,6 +201,7 @@ export function formatSessionTokens(n: number): string {
198
201
  return `${Math.floor(v)}`;
199
202
  }
200
203
 
204
+ /** Cost with precision that fits its size (`$0.0042`); in: the status bar and banner, out: a string. */
201
205
  export function formatSessionCost(c?: number): string {
202
206
  if (!c || c <= 0) return "$0.00";
203
207
  if (c < 0.0001) return `$${c.toFixed(6)}`;
@@ -246,10 +250,12 @@ export class SessionTelemetry {
246
250
  return turnCost;
247
251
  }
248
252
 
253
+ // Zeroes the totals; in: callers starting a fresh session, out: this.totals.
249
254
  reset(): void {
250
255
  this.totals = emptyTotals();
251
256
  }
252
257
 
258
+ // A copy of the totals for saving; in: buildSessionData/JSON.stringify, out: a SessionTotals.
253
259
  toJSON(): SessionTotals {
254
260
  return { ...this.totals };
255
261
  }
@@ -343,6 +349,7 @@ export interface SessionAgentLike {
343
349
  context: { systemPrompt?: string; messages: Message[] };
344
350
  }
345
351
 
352
+ // Slims worker traces for the snapshot (one key per tracking id, repeated final text dropped, reasoning kept); in: buildSessionData, out: the `subagents` field.
346
353
  function snapshotSubagents(traces: Record<string, PersistedSubAgentTrace> | undefined): Record<string, PersistedSubAgentTrace> | undefined {
347
354
  if (!traces) return undefined;
348
355
  const keys = Object.keys(traces);
@@ -562,12 +569,13 @@ function toCacheOption(raw: unknown): CacheOption | undefined {
562
569
  return typeof retention === "string" ? legacy[retention] : undefined;
563
570
  }
564
571
 
572
+ // Parses any supported snapshot shape (current, legacy pretty JSON, JSONL, ancient) or flags it over-limit; in: raw file text, out: a session, null or OVER_LIMIT.
565
573
  function parseSession(raw: string, limits?: SessionLimits): PersistedAgentSession | null | typeof OVER_LIMIT {
566
574
  const maxMessages = limits?.maxMessages ?? MAX_SESSION_MESSAGES;
567
575
  const maxBytes = limits?.maxBytes ?? MAX_SESSION_JSON_BYTES;
568
576
  const text = (raw ?? "").trim();
569
577
  if (!text) return null;
570
- // Q-10: oversized payloads are rejected, but flagged as over-limit (NOT corrupt).
578
+ // Oversized payloads are rejected, but flagged as over-limit (NOT corrupt).
571
579
  if (text.length > maxBytes) return OVER_LIMIT;
572
580
  try {
573
581
  // Legacy JSONL: lines shaped {type: session|mainModel|subagentModel|metrics|message|...}
@@ -618,7 +626,7 @@ function parseSession(raw: string, limits?: SessionLimits): PersistedAgentSessio
618
626
  }
619
627
  }
620
628
  if (!sessionId && messages.length === 0) return null;
621
- // Q-10: oversize guard — flagged as over-limit, not corrupt.
629
+ // Oversize guard — flagged as over-limit, not corrupt.
622
630
  if (messages.length > maxMessages) return OVER_LIMIT;
623
631
  return {
624
632
  version: 1,
@@ -637,7 +645,7 @@ function parseSession(raw: string, limits?: SessionLimits): PersistedAgentSessio
637
645
  }
638
646
  const obj: any = JSON.parse(text);
639
647
  if (!obj || typeof obj !== "object") return null;
640
- // Q-10: version check — reject unknown versions, accept legacy shapes
648
+ // Version check — reject unknown versions, accept legacy shapes
641
649
  // that predate versioning (no version field).
642
650
  if ("version" in obj && (obj as { version?: unknown }).version !== 1) return null;
643
651
  // Very old single-JSON shape without version: {sessionId, model, context:{messages}}
@@ -780,10 +788,12 @@ export function saveSessionFile(
780
788
 
781
789
  const MEDIA_PART_KEYS = ["image", "audio", "video", "file"] as const;
782
790
 
791
+ // True for an http(s) link, which stays a reference instead of being saved as media; in: embeddedMedia, out: a boolean.
783
792
  function isHttpRef(value: string): boolean {
784
793
  return value.startsWith("http://") || value.startsWith("https://");
785
794
  }
786
795
 
796
+ // Splits a `data:<mime>;base64,<payload>` string; in: embeddedMedia, out: its mime type and payload or undefined.
787
797
  function parseDataUrl(value: string): { mimeType: string; base64: string } | undefined {
788
798
  const m = value.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,(.+)$/s);
789
799
  if (m && m[2]) return { mimeType: m[1] || "application/octet-stream", base64: m[2] };
@@ -797,6 +807,7 @@ function looksLikeEmbeddedBase64(value: string): boolean {
797
807
  return /^[A-Za-z0-9+/=\n\r]+$/.test(value.slice(0, 500));
798
808
  }
799
809
 
810
+ // File extension for saved media from its mime type, else its filename, else `bin`; in: embeddedMedia, out: the extension.
800
811
  function extForMedia(mimeType?: string, filename?: string): string {
801
812
  const mime = (mimeType || "").toLowerCase().split(";")[0]!.trim();
802
813
  const known: Record<string, string> = {
@@ -850,6 +861,7 @@ export function embeddedMedia(part: ContentPart): { key: (typeof MEDIA_PART_KEYS
850
861
  return { key, bytes, ...(mimeType ? { mimeType } : {}), ext: extForMedia(mimeType, typeof p["filename"] === "string" ? (p["filename"] as string) : undefined) };
851
862
  }
852
863
 
864
+ // Copy of the messages that keeps media bytes (structuredClone, else JSON); in: extractMediaToDir/resolveMediaPaths, out: a new array.
853
865
  function cloneMessages(messages: Message[]): Message[] {
854
866
  try {
855
867
  const g: any = globalThis as any;
@@ -966,7 +978,7 @@ export function saveSessionDir(
966
978
  agent: SessionAgentLike,
967
979
  telemetry?: SessionTelemetry | SessionTotals | null
968
980
  ): string {
969
- // Q-28-partial: sessionDirFor already enforces assertValidSessionId.
981
+ // sessionDirFor already enforces assertValidSessionId.
970
982
  const sessionDir = sessionDirFor(rootDir, agent.sessionId);
971
983
  try {
972
984
  fs.mkdirSync(sessionDir, { recursive: true });
@@ -1127,7 +1139,7 @@ export function findLatestSessionDir(rootDir: string): string | null {
1127
1139
  }
1128
1140
 
1129
1141
  // ---------------------------------------------------------------------------
1130
- // Q-10: session validation (append-only; deserializeSession above only gained
1142
+ // Session validation (append-only; deserializeSession above only gained
1131
1143
  // size/version guards, no behavior change otherwise).
1132
1144
  // ---------------------------------------------------------------------------
1133
1145
 
@@ -1136,6 +1148,7 @@ export const MAX_SESSION_MESSAGES = 50_000;
1136
1148
  /** Default max raw JSON size accepted when loading a session snapshot (override via `SessionLimits`). */
1137
1149
  export const MAX_SESSION_JSON_BYTES = 100 * 1024 * 1024;
1138
1150
 
1151
+ // A SessionLoadError-named Error with the SDK prefix; in: validateSessionData, out: the error.
1139
1152
  function sessionLoadError(message: string): Error {
1140
1153
  const e = new Error(`[Agent Accelerator] Invalid session: ${message}`);
1141
1154
  e.name = "SessionLoadError";
@@ -1143,9 +1156,9 @@ function sessionLoadError(message: string): Error {
1143
1156
  }
1144
1157
 
1145
1158
  /**
1146
- * Manual Zod-like validation for session snapshots (Q-10, no new deps).
1159
+ * Manual Zod-like validation for session snapshots (no new deps).
1147
1160
  * Checks `version===1`, string `sessionId`, array `messages`, and oversize
1148
- * (>5000 messages or >10MB JSON). Throws a `SessionLoadError`-named Error
1161
+ * (more than `MAX_SESSION_MESSAGES` = 50,000 messages or `MAX_SESSION_JSON_BYTES` = 100 MB of JSON). Throws a `SessionLoadError`-named Error
1149
1162
  * on failure; returns the typed session on success.
1150
1163
  */
1151
1164
  export function validateSessionData(data: unknown): PersistedAgentSession {
@@ -1178,12 +1191,12 @@ export function validateSessionData(data: unknown): PersistedAgentSession {
1178
1191
  }
1179
1192
 
1180
1193
  // ---------------------------------------------------------------------------
1181
- // Q-34: context-window lookup without the legacy 1M fallback.
1194
+ // Context-window lookup without the legacy 1M fallback.
1182
1195
  // ---------------------------------------------------------------------------
1183
1196
 
1184
1197
  /**
1185
- * Returns the catalog context window for a model, or `undefined` when unknown
1186
- * (Q-34). Prefer this over `getSessionContextWindow` for new code.
1198
+ * Returns the catalog context window for a model, or `undefined` when unknown.
1199
+ * Prefer this over `getSessionContextWindow` for new code.
1187
1200
  */
1188
1201
  export function getSessionContextWindowOrUndefined(modelStr: string): number | undefined {
1189
1202
  try {
@@ -1197,7 +1210,7 @@ export function getSessionContextWindowOrUndefined(modelStr: string): number | u
1197
1210
  }
1198
1211
 
1199
1212
  // ---------------------------------------------------------------------------
1200
- // Q-41: telemetry bus lives in `../telemetry.ts` (dependency-free, so low-level
1213
+ // Telemetry bus lives in `../telemetry.ts` (dependency-free, so low-level
1201
1214
  // modules such as the catalog cache and event stream can emit without import
1202
1215
  // cycles). Re-exported here for backward compatibility.
1203
1216
  // ---------------------------------------------------------------------------
@@ -1205,7 +1218,7 @@ import { telemetryBus } from "../telemetry.ts";
1205
1218
  export { telemetryBus };
1206
1219
  export type { TelemetryEvent, TelemetryListener } from "../telemetry.ts";
1207
1220
 
1208
- /** Reports a usage/cost anomaly via the global hook + telemetry bus (Q-35). Never logs. */
1221
+ /** Reports a usage/cost anomaly via the global hook + telemetry bus. Never logs. */
1209
1222
  export function reportUsageAnomaly(detail: { type: string; [k: string]: unknown }): void {
1210
1223
  try {
1211
1224
  (globalThis as unknown as { __agentAccelAnomaly?: (d: unknown) => void }).__agentAccelAnomaly?.(detail);
@@ -1220,10 +1233,10 @@ export function reportUsageAnomaly(detail: { type: string; [k: string]: unknown
1220
1233
  }
1221
1234
 
1222
1235
  // ---------------------------------------------------------------------------
1223
- // Q-35: canonical usage invariants.
1236
+ // Canonical usage invariants.
1224
1237
  // ---------------------------------------------------------------------------
1225
1238
  /**
1226
- * Canonical usage invariants (provider-agnostic, Q-35):
1239
+ * Canonical usage invariants (provider-agnostic):
1227
1240
  * - all token counts are finite integers >= 0;
1228
1241
  * - `cachedTokens` / `cacheReadTokens` are a SUBSET of `inputTokens`
1229
1242
  * (cache hits cannot exceed input; aggregates clamp — see `accumulateUsage`
@@ -1274,13 +1287,13 @@ export function assertUsageInvariants(usage: TokenUsage): void {
1274
1287
  }
1275
1288
 
1276
1289
  // ---------------------------------------------------------------------------
1277
- // Q-36: one cost model — integer micro-USD accumulation.
1290
+ // One cost model — integer micro-USD accumulation.
1278
1291
  // ---------------------------------------------------------------------------
1279
1292
 
1280
- /** Where a priced cost came from (Q-36). Unknown stays unknown, never 0. */
1293
+ /** Where a priced cost came from. Unknown stays unknown, never 0. */
1281
1294
  export type CostSource = "provider" | "catalog" | "unknown";
1282
1295
 
1283
- /** Integer micro-USD breakdown behind a priced cost (Q-36). */
1296
+ /** Integer micro-USD breakdown behind a priced cost. */
1284
1297
  export interface CostBreakdown {
1285
1298
  inputMicroUSD: number;
1286
1299
  outputMicroUSD: number;
@@ -1302,7 +1315,7 @@ export type PricedCost = NonNullable<TokenUsage["cost"]> & {
1302
1315
  };
1303
1316
 
1304
1317
  /**
1305
- * Service-tier price multipliers (Q-36). Aligned with pi
1318
+ * Service-tier price multipliers. Aligned with pi
1306
1319
  * (`api/openai-responses.ts:387`): `flex` trades latency for ~0.5x price,
1307
1320
  * `priority`/`fast` reserve capacity at ~2x (`2.5x` for `gpt-5.5`).
1308
1321
  * Standard/default is 1.0. Estimates because vendors do not publish exact
@@ -1314,6 +1327,7 @@ const SERVICE_TIER_MULTIPLIERS: Record<string, number> = {
1314
1327
  fast: 2,
1315
1328
  };
1316
1329
 
1330
+ // Price multiplier for a service tier (flex 0.5, priority 2, 2.5 for gpt-5.5; estimates); in: priceUsage, out: the multiplier and tier name.
1317
1331
  function serviceTierMultiplier(serviceTier?: string, modelId?: string): { multiplier: number; tier?: string } {
1318
1332
  if (!serviceTier) return { multiplier: 1 };
1319
1333
  const lower = serviceTier.toLowerCase();
@@ -1327,12 +1341,13 @@ function serviceTierMultiplier(serviceTier?: string, modelId?: string): { multip
1327
1341
  return m !== undefined ? { multiplier: m, tier: serviceTier } : { multiplier: 1, tier: serviceTier };
1328
1342
  }
1329
1343
 
1344
+ // Integer micro-USD to dollars; in: priceUsage, out: a number.
1330
1345
  function toDollars(microUSD: number): number {
1331
1346
  return microUSD / 1_000_000;
1332
1347
  }
1333
1348
 
1334
1349
  /**
1335
- * Resolves effective per-1M pricing for a usage snapshot (Q-36). When
1350
+ * Resolves effective per-1M pricing for a usage snapshot. When
1336
1351
  * `usage.inputTokens > 200_000` and the catalog carries `cost.tiers` or
1337
1352
  * `cost.context_over_200k`, the over-200k tier prices apply; otherwise base
1338
1353
  * `pricing.*` (falling back to legacy `cost.*`) applies.
@@ -1389,7 +1404,7 @@ function resolveEffectivePricing(
1389
1404
  }
1390
1405
 
1391
1406
  /**
1392
- * Prices one usage snapshot with integer micro-USD accumulation (Q-36).
1407
+ * Prices one usage snapshot with integer micro-USD accumulation.
1393
1408
  *
1394
1409
  * - A provider-reported `usage.cost.totalCost` (a number, 0 included: free or cached) wins (source `provider`).
1395
1410
  * - Else catalog pricing via `resolveEffectivePricing` (source `catalog`).
@@ -1483,7 +1498,7 @@ export function priceUsage(
1483
1498
  }
1484
1499
 
1485
1500
  /**
1486
- * Computes and attaches catalog pricing to a usage snapshot (Q-36).
1501
+ * Computes and attaches catalog pricing to a usage snapshot.
1487
1502
  * Keeps provider-reported costs untouched; returns the cost (or undefined
1488
1503
  * when unknown). Never fabricates a 0 object for unknown pricing.
1489
1504
  */
@@ -39,6 +39,7 @@ export function emptyUsage(): UsageTotals {
39
39
  };
40
40
  }
41
41
 
42
+ // A finite whole number >= 0, else 0; in: the usage maths and readUsage, out: a safe count.
42
43
  const count = (n: unknown): number => (typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : 0);
43
44
 
44
45
  /** A model reference (string or catalog spec) as a plain string for a report line. */
@@ -0,0 +1,13 @@
1
+ # src/streaming/
2
+
3
+ | File | What it is |
4
+ | :-- | :-- |
5
+ | `event-stream.ts` | `AssistantMessageEventStream`: an async-iterable event stream with listeners (`on`/`off`), a `result()` promise, `cancel()` and bounded buffering. Providers produce it; the loop and `Agent` consume it. |
6
+ | `sse-parser.ts` | `SSEParser`: incremental Server-Sent Events framing that survives events split across network chunks. Internal (not exported from the barrel). |
7
+
8
+ ## Invariants
9
+ - The buffer holds at most 1000 events (oldest dropped, reported on the bus); an event over 1 MB is dropped. `{ buffer: false }` keeps none (for a caller that only wants `result()`).
10
+ - `end()` without a response fails the stream instead of leaving `result()` hanging.
11
+ - A cancel is recognised by the `AbortError` name only, never by message text.
12
+ - Breaking out of `for await` cancels the stream, which tears down the HTTP request.
13
+ - A throwing listener never breaks the stream; the first error is kept in `getListenerError()`.
@@ -4,7 +4,7 @@ import { telemetryBus } from "../telemetry.ts";
4
4
  /** Listener invoked for a matching stream event type or `*`. */
5
5
  export type StreamEventListener = (event: StreamEvent) => void;
6
6
 
7
- /** Options for AssistantMessageEventStream (Q-29). */
7
+ /** Options for AssistantMessageEventStream. */
8
8
  export interface EventStreamOptions {
9
9
  /**
10
10
  * When false, `partialText`/`partialThinking` cumulative fields are stripped
@@ -25,11 +25,12 @@ type Resolver = {
25
25
  reject: (err: Error) => void;
26
26
  };
27
27
 
28
- /** Default max buffered events (Q-29). */
28
+ /** Default max buffered events. */
29
29
  export const MAX_BUFFERED_EVENTS = 1000;
30
- /** Default max approx bytes per delta event (Q-29). */
30
+ /** Default max approx bytes per delta event. */
31
31
  export const MAX_EVENT_BYTES = 1_048_576;
32
32
 
33
+ // Approximate size of an event's text fields; in: push's oversize guard, out: a character count.
33
34
  function approxEventBytes(event: StreamEvent): number {
34
35
  let n = 0;
35
36
  if (typeof event.delta === "string") n += event.delta.length;
@@ -39,9 +40,10 @@ function approxEventBytes(event: StreamEvent): number {
39
40
  return n;
40
41
  }
41
42
 
43
+ // Reports a dropped or overflowing event on the bus and the legacy hook, never throwing; in: push/enqueueBounded, out: telemetryBus.
42
44
  function reportStreamAnomaly(detail: { type: string; [k: string]: unknown }): void {
43
45
  // why: bus + hook only, never console — streams are hot paths and logging
44
- // per-drop would spam and break no-console policy (Q-29/Q-41).
46
+ // per-drop would spam and break no-console policy.
45
47
  telemetryBus.emitWarning(detail.type, `[Agent Accelerator] stream anomaly: ${detail.type}`, detail);
46
48
  try {
47
49
  (globalThis as unknown as { __agentAccelAnomaly?: (d: unknown) => void }).__agentAccelAnomaly?.(detail);
@@ -66,7 +68,7 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
66
68
  private readonly buffered: boolean;
67
69
  private readonly maxBufferedEvents: number;
68
70
  private readonly maxEventBytes: number;
69
- /** First listener exception captured for inspection (Q-29/Q-41). */
71
+ /** First listener exception captured for inspection. */
70
72
  private listenerError: Error | null = null;
71
73
  /** Count of events dropped by bounds (buffer cap / oversize / unbuffered). */
72
74
  private droppedEvents = 0;
@@ -105,7 +107,7 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
105
107
  return this.listenerError;
106
108
  }
107
109
 
108
- /** Number of events dropped by Q-29 bounds. */
110
+ /** Number of events dropped by bounds. */
109
111
  getDroppedCount(): number {
110
112
  return this.droppedEvents;
111
113
  }
@@ -114,7 +116,7 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
114
116
  push(event: StreamEvent): void {
115
117
  if (this.finished) return;
116
118
 
117
- // Q-29: 1MB oversize guard — drop oversized deltas (no console).
119
+ // 1MB oversize guard — drop oversized deltas (no console).
118
120
  if (approxEventBytes(event) > this.maxEventBytes) {
119
121
  this.droppedEvents++;
120
122
  reportStreamAnomaly({ type: "stream_event_oversize", eventType: event.type });
@@ -130,7 +132,7 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
130
132
 
131
133
  if (out.type === "error") {
132
134
  this.error = (out.error instanceof Error ? out.error : new Error(String(out.error))) ?? new Error("Unknown error in stream");
133
- // Single error semantic (Q-29): deliver the error event to listeners,
135
+ // Single error semantic: deliver the error event to listeners,
134
136
  // reject result(), mark finished, reject pending iterators, and queue
135
137
  // the error event so a late iterator observes it before failing.
136
138
  this.deliverToListeners(out);
@@ -154,13 +156,14 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
154
156
  resolver.resolve({ value: out, done: false });
155
157
  } else {
156
158
  // No waiter right now (typical between `next()` polls of a burst):
157
- // buffer for the iterator with a hard cap (Q-29). Overflow drops the
159
+ // buffer for the iterator with a hard cap. Overflow drops the
158
160
  // oldest with an anomaly report — see `enqueueBounded`. A stream built
159
161
  // with `buffer: false` has no iterator to feed and keeps nothing.
160
162
  if (this.buffered) this.enqueueBounded(out);
161
163
  }
162
164
  }
163
165
 
166
+ // Calls the listeners of the event's type and `*`, remembering the first listener error instead of throwing; in: push, out: those callbacks.
164
167
  private deliverToListeners(event: StreamEvent): void {
165
168
  const specificListeners = this.listeners.get(event.type);
166
169
  if (specificListeners) {
@@ -187,10 +190,11 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
187
190
  }
188
191
  }
189
192
 
193
+ // Buffers an event for the iterator, dropping the oldest non-error events beyond the cap; in: push, out: this.queue.
190
194
  private enqueueBounded(event: StreamEvent): void {
191
195
  this.queue.push(event);
192
196
  while (this.queue.length > this.maxBufferedEvents) {
193
- // Drop oldest buffered event to bound memory (Q-29). Error events are
197
+ // Drop oldest buffered event to bound memory. Error events are
194
198
  // never dropped: if the oldest is an error, drop the next-oldest
195
199
  // non-error instead; if the queue is all errors, keep them.
196
200
  if (this.queue[0]?.type === "error") {
@@ -209,7 +213,7 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
209
213
  end(finalResponse?: AgentResponse): void {
210
214
  if (this.finished) return;
211
215
  if (!finalResponse) {
212
- // Q-29: never hang result() — ending without a response is a
216
+ // Never hang result() — ending without a response is a
213
217
  // programming error, so fail loudly instead of leaving consumers
214
218
  // waiting forever.
215
219
  this.fail(new Error("end() requires response"));
@@ -289,6 +293,7 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
289
293
  return this.finalResultPromise;
290
294
  }
291
295
 
296
+ // Iterator over the stream: buffered events first, then waits for new ones; breaking out cancels the stream and a failed stream rejects after delivering its error event; in: for-await consumers, out: StreamEvents.
292
297
  [Symbol.asyncIterator](): AsyncIterator<StreamEvent> {
293
298
  let iteratorDone = false;
294
299
  return {
@@ -1,34 +1,84 @@
1
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, by design: this is observability, not state). `turn.thinking`
8
- * (full reasoning text) is never written. Takes the home's one-process lock.
2
+ * Opt-in disk sink for the telemetry bus (`AgentConfig.telemetry.persist`). Appends one JSON line per event.
3
+ * An event that carries the id of a persisted session (`data.sessionId`) goes to that session's own file,
4
+ * `<session folder>/telemetry.ndjson`, so a session's trace sits next to its journal and tool logs; every other event
5
+ * goes to `<home>/telemetry/<YYYY-MM-DD>.ndjson`. Nothing is ever written outside a home folder: a session folder
6
+ * that is not inside a home that enabled telemetry (a `persist.dir` pointing elsewhere) falls back to the daily file.
7
+ * Each event is a single synchronous append, so a process crash keeps every event already emitted: a
8
+ * `tool.call_started` with no ending event was in flight, and the last `tool.progress` shows how far it got. Power loss
9
+ * is not covered (no fsync, by design: this is observability, not state). `turn.thinking` (full reasoning text) is never
10
+ * written. Several processes may enable the same home: each event is one append, so lines never mix.
9
11
  */
10
12
  import * as fs from "node:fs";
11
13
  import * as path from "node:path";
12
14
  import { telemetryBus } from "./telemetry.ts";
15
+ import type { TelemetryEvent } from "./telemetry.ts";
13
16
  import { safeStringify } from "./utils/serialization.ts";
14
- import { lockFolder } from "./utils/durable.ts";
15
17
 
16
- const sinks = new Set<string>();
18
+ /** Homes (resolved paths) that asked for telemetry. */
19
+ const homes = new Set<string>();
20
+ /** Where each persisted session lives, by session id. */
21
+ const sessionFolders = new Map<string, string>();
22
+ let unsubscribe: (() => void) | undefined;
17
23
 
18
- /** Starts appending bus events under `home` (idempotent per folder). */
19
- export function persistTelemetry(home: string): void {
20
- const dir = path.join(home, "telemetry");
21
- if (sinks.has(dir)) return;
22
- lockFolder(home);
23
- sinks.add(dir);
24
- telemetryBus.onEvent((event) => {
25
- if (event.code === "turn.thinking") return;
26
- try {
27
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
28
- const file = path.join(dir, `${new Date(event.timestamp).toISOString().slice(0, 10)}.ndjson`);
29
- fs.appendFileSync(file, safeStringify(event) + "\n", { mode: 0o600 });
30
- } catch {
31
- // why: a full disk must never fail a run, and reporting it would re-enter this listener.
24
+ // True when `folder` is `home` itself or below it; in: the routing decision, out: a boolean.
25
+ function inside(home: string, folder: string): boolean {
26
+ const rel = path.relative(home, folder);
27
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
28
+ }
29
+
30
+ // Appends one line to a file, creating its folder; a full disk must never fail a run, and reporting it would re-enter this listener; in: the listener, out: the file.
31
+ function append(file: string, line: string): void {
32
+ try {
33
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
34
+ fs.appendFileSync(file, line, { mode: 0o600 });
35
+ } catch {
36
+ // why: observability must never fail a run.
37
+ }
38
+ }
39
+
40
+ // The bus listener: skips reasoning text, writes a session's event to its own folder when that folder is inside an enabled home, else to the daily file of every enabled home; in: each bus event, out: NDJSON files.
41
+ function onEvent(event: TelemetryEvent): void {
42
+ if (event.code === "turn.thinking") return;
43
+ const line = safeStringify(event) + "\n";
44
+ const id = (event.data as { sessionId?: unknown } | undefined)?.sessionId;
45
+ const folder = typeof id === "string" ? sessionFolders.get(id) : undefined;
46
+ if (folder && [...homes].some((h) => inside(h, folder))) {
47
+ append(path.join(folder, "telemetry.ndjson"), line);
48
+ return;
49
+ }
50
+ const day = new Date(event.timestamp).toISOString().slice(0, 10);
51
+ for (const home of homes) {
52
+ if (!fs.existsSync(home)) {
53
+ homes.delete(home); // a deleted home is not recreated by a stray event
54
+ continue;
32
55
  }
33
- });
56
+ append(path.join(home, "telemetry", `${day}.ndjson`), line);
57
+ }
58
+ }
59
+
60
+ /** Starts appending bus events for `home` (idempotent per folder); one listener serves every enabled home. */
61
+ export function persistTelemetry(home: string): void {
62
+ const resolved = path.resolve(home);
63
+ unsubscribe = telemetryBus.onEvent(onEvent); // listeners are a Set, so this is idempotent, and it restores the sink after `telemetryBus.clear()`
64
+ if (homes.has(resolved)) return;
65
+ homes.add(resolved);
66
+ }
67
+
68
+ /** Remembers where a persisted session lives, so its events are written next to it; in: the Agent constructor and `importSession`, out: the routing map. */
69
+ export function registerSessionFolder(sessionId: string, folder: string): void {
70
+ sessionFolders.set(sessionId, path.resolve(folder));
71
+ }
72
+
73
+ /** Forgets a session's folder (its events fall back to the daily file); in: `disposeSession` and `importSession`, out: the routing map. */
74
+ export function unregisterSessionFolder(sessionId: string): void {
75
+ sessionFolders.delete(sessionId);
76
+ }
77
+
78
+ /** Forgets every enabled home and session and removes the listener. Test hook (not part of the public entry). */
79
+ export function resetTelemetryFile(): void {
80
+ homes.clear();
81
+ sessionFolders.clear();
82
+ unsubscribe?.();
83
+ unsubscribe = undefined;
34
84
  }
package/src/telemetry.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * In-process telemetry bus (Q-41). Library code routes warnings, anomalies and
2
+ * In-process telemetry bus. Library code routes warnings, anomalies and
3
3
  * per-turn traces here instead of `console.*`. The default has zero listeners,
4
4
  * so emission is a no-op; CLIs, tests and observability layers attach with
5
5
  * `telemetryBus.onEvent`. Intentionally dependency-free: every module in the
@@ -7,7 +7,7 @@
7
7
  * it without creating an import cycle.
8
8
  */
9
9
 
10
- /** Telemetry event emitted via the in-process bus (Q-41). */
10
+ /** Telemetry event emitted via the in-process bus. */
11
11
  export interface TelemetryEvent {
12
12
  code: string;
13
13
  message: string;
@@ -30,6 +30,7 @@ interface TelemetryBus {
30
30
  clear(): void;
31
31
  }
32
32
 
33
+ /** The in-process telemetry bus: listeners subscribe with `onEvent`; emission never throws and does nothing without listeners. */
33
34
  export const telemetryBus: TelemetryBus = (() => {
34
35
  const listeners = new Set<TelemetryListener>();
35
36
  const deliver = (event: TelemetryEvent): void => {
@@ -0,0 +1,19 @@
1
+ # src/tools/
2
+
3
+ Defining tools and running them.
4
+
5
+ | File | What it is |
6
+ | :-- | :-- |
7
+ | `tool.ts` | `tool()` factory, name validation, `toStandardToolDeclarations`, option normalizers. |
8
+ | `schema.ts` | Zod to JSON Schema, `$ref` resolution, `stripSchemaForGoogle`. |
9
+ | `executor.ts` | `executeToolCalls`: validation, concurrency (8 global + per-tool), attempts, timeout, result cap, error scrubbing, `ctx.stop`, `tool.*` events, per-call logs. |
10
+ | `subagent-result.ts` | The private-Symbol brand that marks a result as made by the SDK's delegation tools. |
11
+
12
+ ## Invariants
13
+ - Tool names match `^[a-zA-Z0-9_-]{1,64}$` and must not collide after normalization (checked in the `Agent` constructor).
14
+ - One attempt by default; only idempotent tools retry transient failures; the timeout is best effort (`ctx.signal` is cooperative), and an uncooperative tool no longer holds an aborted run.
15
+ - Results are capped by serialized size (default 20000 chars); nested error stacks and absolute paths are scrubbed. The telemetry bus gets full detail, the model gets the scrubbed text.
16
+ - A truncated call (`{ __truncated_raw }`) never executes.
17
+ - `ctx.stop(reason)` throws a private signal; the batch finishes, then the loop ends the run as `stopped`.
18
+ - Only branded envelopes get their `_subagentMetadata` trusted; the executor passes them through untouched.
19
+ - Failures return `Error executing <name>: <msg>` plus a JSON block (attempts, timedOut, last progress, hint).