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
@@ -1,4 +1,7 @@
1
- import type { AgentConfig, AgentRunOptions, MidSessionConfig } from "../types/agent.ts";
1
+ import type { AgentConfig, AgentRunOptions, MidSessionConfig, ToolChoiceOption, RetryPolicyConfig, Budget, BudgetState } from "../types/agent.ts";
2
+ import type { TokenUsage } from "../types/core.ts";
3
+ import type { ProviderRequestOptions } from "../types/model.ts";
4
+ import { normalizeThinkingV2, resolveWhenBusy, normalizeWorkerTimeout } from "../tools/tool.ts";
2
5
  import type { ToolDefinition } from "../types/tool.ts";
3
6
  import type { ThinkingLevel, ThinkingConfig, CacheConfig, ServiceTier } from "../types/core.ts";
4
7
  import type { ContentPart } from "../types/message.ts";
@@ -7,14 +10,33 @@ import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
7
10
  import { AgentContext } from "./context.ts";
8
11
  import { resolveModel } from "../providers/registry.ts";
9
12
  import { buildAgentTools, createSubagentSpawnTool } from "./delegation.ts";
10
- import { convert_document_to_markdown } from "../utils/documents.ts";
13
+ import {
14
+ normalizeToolMap,
15
+ resolveDynamicModelInput,
16
+ resolveMainModel,
17
+ } from "./worker.ts";
18
+ import * as path from "node:path";
19
+ import { createConvertDocumentTool } from "../utils/documents.ts";
20
+ import { registerAgent } from "../scheduler/scheduler.ts";
21
+ import { resolveHome } from "../scheduler/store.ts";
22
+ import { persistTelemetry } from "../telemetry-file.ts";
23
+ import { createScheduledActionsTool, getDatetimeTool } from "../scheduler/tool.ts";
24
+ import { assertNoToolCollisions } from "../tools/executor.ts";
11
25
  import { runAgentLoop, streamAgentLoop, type SteerEntry } from "./loop.ts";
12
- import { createSessionId } from "../utils/session.ts";
13
- import { getSubAgentTrace, subscribeToSubAgent, listSubAgentTraceIds, type SubAgentTrace } from "./delegation.ts";
14
- import { saveSessionDir, saveSessionFile } from "../session/store.ts";
15
- import { clearSessionRouting } from "../providers.ts";
26
+ import { createSessionId, nowMs } from "../utils/session.ts";
27
+ import {
28
+ chargeBudgetUsage,
29
+ checkBudget,
30
+ getSubAgentTraceForSessions,
31
+ subscribeToSubAgentForSessions,
32
+ listSubAgentTraceIdsForSessions,
33
+ type SubAgentTrace,
34
+ } from "./delegation.ts";
35
+ import { saveSessionDir, saveSessionFile, findInvalidMessage, telemetryBus } from "../session/store.ts";
36
+ import { isLocalPathInput } from "../utils/safe-io.ts";
37
+ import { clearSessionRouting, normalizeStructuredOutput, normalizeStop } from "../providers.ts";
38
+ import type { StructuredOutputSpec } from "../types/model.ts";
16
39
  import { clearInteractionChains } from "../providers/google.ts";
17
- import { getModel, getSubModel } from "../utils/env.ts";
18
40
  import { validateModelThinking, ensureModelCatalogFresh } from "../models/catalog.ts";
19
41
  import { buildSessionData, type PersistedAgentSession, type SessionTotals,} from "../session/store.ts";
20
42
  import { SessionTelemetry } from "../session/store.ts";
@@ -32,7 +54,7 @@ export function resolveEffectiveThinking(
32
54
  return { enabled: true, level: overrideLevel as ThinkingConfig["level"] };
33
55
  }
34
56
 
35
- import { ConfigError } from "../types/errors.ts";
57
+ import { ConfigError, SessionLoadError } from "../types/errors.ts";
36
58
 
37
59
  /**
38
60
  * Disposers for the manual user-signal forward listeners (Q-21 fallback when
@@ -51,6 +73,7 @@ export interface AgentRunLimits {
51
73
  maxTurns?: number;
52
74
  deadlineMs?: number;
53
75
  maxToolCallsPerRun?: number;
76
+ maxTotalTokens?: number;
54
77
  requestTimeoutMs: number;
55
78
  firstByteTimeoutMs: number;
56
79
  streamIdleTimeoutMs: number;
@@ -67,6 +90,7 @@ function resolveAgentLimits(config: { limits?: Record<string, unknown> }): Agent
67
90
  ...(num(raw.maxToolCallsPerRun) !== undefined
68
91
  ? { maxToolCallsPerRun: Math.max(1, Math.floor(num(raw.maxToolCallsPerRun)!)) }
69
92
  : {}),
93
+ ...(num(raw.maxTotalTokens) !== undefined ? { maxTotalTokens: Math.max(1, Math.floor(num(raw.maxTotalTokens)!)) } : {}),
70
94
  requestTimeoutMs: num(raw.requestTimeoutMs) ?? 300_000,
71
95
  firstByteTimeoutMs: num(raw.firstByteTimeoutMs) ?? 120_000,
72
96
  streamIdleTimeoutMs: num(raw.streamIdleTimeoutMs) ?? 300_000,
@@ -134,17 +158,54 @@ export class Agent {
134
158
  thinkingLevel?: ThinkingLevel;
135
159
  tools: Record<string, ToolDefinition>;
136
160
  timeout: number;
161
+ /** Scheduled actions (`scheduled_actions` + `get_datetime` tools) are on. */
162
+ allowScheduledActions: boolean;
137
163
  };
164
+ /** Home folder for everything written to disk (`sessions/`, `scheduled_actions/`, `telemetry/`). */
165
+ readonly home: string;
138
166
  /** Registered model-callable tools. */
139
167
  readonly tools: Record<string, ToolDefinition> = {};
140
168
  /** Normalized reasoning configuration. */
141
169
  readonly thinkingConfig?: ThinkingConfig;
170
+ /** Canonical structured-output request (raw `AgentConfig.output`). */
171
+ readonly output?: unknown;
172
+ /** Normalized structured-output spec sent to providers. */
173
+ readonly outputSpec?: StructuredOutputSpec;
142
174
  /** Cache retention/session settings. */
143
175
  readonly cacheConfig?: CacheConfig;
144
176
  /** Provider service tier. */
145
177
  readonly serviceTier?: ServiceTier;
146
178
  /** Convert `file` parts client-side for pdf-incapable models. */
147
179
  readonly bypassInputFileModality: boolean;
180
+ /**
181
+ * Local-file policy. `roots` set: every local-path media part and the
182
+ * built-in document tool must resolve inside them (`[]` denies all paths).
183
+ * Unset: developer-supplied parts are unrestricted and the model-callable
184
+ * tool is limited to the working directory.
185
+ */
186
+ readonly fileAccess?: { roots?: string[] };
187
+ /** Failure policy for the conversation (see `AgentConfig.onFailure`). */
188
+ readonly onFailure: "auto" | "rollback" | "keep";
189
+ /** Wire-capture policy for `AgentResponse.raw` (default `"redacted"`: bodies replaced by a content-free summary). */
190
+ readonly captureRaw?: false | "redacted" | true;
191
+ /** Constructor config, kept so {@link Agent.fork} can build isolated copies. */
192
+ private readonly forkConfig: AgentConfig;
193
+ /** Opt-in per-run budget (tokens / cost / duration / sub-agent count), enforced by the loop. */
194
+ readonly budget?: Budget;
195
+ /** Live counters behind {@link Agent.budget}; reset at the start of every run. */
196
+ budgetState?: BudgetState;
197
+ /** Google chain-TTL tier (`free` 23h default / `paid` 54d). */
198
+ readonly googleTier?: "free" | "paid";
199
+ /** Per-call defaults forwarded to providers; per-run options override them. */
200
+ private readonly callDefaults: {
201
+ toolChoice?: ToolChoiceOption;
202
+ maxOutputTokens?: number;
203
+ stop?: string | string[];
204
+ seed?: number;
205
+ retry?: RetryPolicyConfig;
206
+ env?: Record<string, string>;
207
+ fetch?: typeof fetch;
208
+ };
148
209
  /** Maximum model/tool turns per run. */
149
210
  readonly maxTurns: number;
150
211
  /** Session ID used for history and cache affinity. */
@@ -215,53 +276,29 @@ export class Agent {
215
276
  this.description = config.description || "AI Agent powered by Agent Accelerator";
216
277
  // DX1: only instructions (no systemPrompt alias)
217
278
  this.instructions = config.instructions || "";
218
- const rawModel: any = (config.model as any)?.model ? (config.model as any) : config.model;
219
- // Support ModelProviderInstance {model, apiKey, baseUrl, thinkingLevel} and ModelSpec {id, provider}
220
- if (rawModel && typeof rawModel === "object" && "model" in rawModel) {
221
- this.modelStringOrSpec = rawModel.model;
222
- } else if (rawModel && typeof rawModel === "object" && "id" in rawModel && "provider" in rawModel) {
223
- this.modelStringOrSpec = rawModel;
224
- } else {
225
- this.modelStringOrSpec = rawModel || getModel();
226
- }
279
+ // Unified model resolution (shared with SubAgent/workers): supports
280
+ // ModelProviderInstance {model,apiKey,...} and ModelSpec {id,provider}.
281
+ const { modelStringOrSpec, rawModel } = resolveMainModel(config.model);
282
+ this.modelStringOrSpec = modelStringOrSpec as string | any;
227
283
  const dynRaw = config.dynamicSubagents;
228
- const dynModelRaw = (dynRaw as any)?.model ?? config.subagentModel;
229
- let dynModel: string | any | undefined;
230
- if (dynModelRaw && typeof dynModelRaw === "object" && "model" in dynModelRaw) {
231
- dynModel = (dynModelRaw as any).model;
232
- } else if (dynModelRaw && typeof dynModelRaw === "object" && "id" in dynModelRaw && "provider" in dynModelRaw) {
233
- dynModel = dynModelRaw;
234
- } else {
235
- dynModel = (dynModelRaw as any) || getSubModel();
236
- }
237
- this.subagentModel = dynModel;
284
+ // Unified worker-model chain: dynamicSubagents.model > subagentModel > SUB_AGENT_MODEL.
285
+ const dynModel = resolveDynamicModelInput(dynRaw, config.subagentModel);
286
+ this.subagentModel = dynModel as string | any | undefined;
238
287
  const dynEnabled = dynRaw ? (dynRaw.enabled ?? true) : false;
239
288
  if (dynRaw) {
240
- const rawTools = (dynRaw as any)?.tools;
241
- const dynTools: Record<string, ToolDefinition> = {};
242
- if (rawTools) {
243
- if (Array.isArray(rawTools)) {
244
- for (const t of rawTools) {
245
- const tName = (t as any)?.name || `tool_${Object.keys(dynTools).length}`;
246
- dynTools[tName] = { ...(t as any), name: tName };
247
- }
248
- } else {
249
- for (const [key, def] of Object.entries(rawTools as Record<string, ToolDefinition>)) {
250
- dynTools[key] = { ...(def as any), name: (def as any)?.name || key };
251
- }
252
- }
253
- }
289
+ const dynTools = normalizeToolMap((dynRaw as { tools?: Record<string, ToolDefinition> | ToolDefinition[] }).tools);
254
290
  const rawMax = (dynRaw as any)?.maxSpawn;
255
291
  const maxSpawn = Number.isFinite(rawMax) ? Math.max(1, Math.floor(rawMax as number)) : 4;
256
- const rawTimeout = (dynRaw as any)?.timeout;
257
- const timeout = Number.isFinite(rawTimeout) ? Math.floor(rawTimeout as number) : 0;
292
+ // `workerTimeoutMs ?? timeout`, or -1 when `modelMaySetTimeout` (see normalizeWorkerTimeout).
293
+ const timeout = normalizeWorkerTimeout(dynRaw as { timeout?: number; workerTimeoutMs?: number; modelMaySetTimeout?: boolean });
258
294
  this.dynamicSubagents = {
259
295
  enabled: dynEnabled,
260
- model: dynModel,
296
+ model: dynModel as string | any | undefined,
261
297
  maxSpawn,
262
298
  thinkingLevel: (dynRaw as any)?.thinkingLevel,
263
299
  tools: dynTools,
264
300
  timeout,
301
+ allowScheduledActions: dynRaw.allowScheduledActions === true,
265
302
  };
266
303
  }
267
304
  this.apiKey = (rawModel as any)?.apiKey || config.apiKey;
@@ -275,9 +312,14 @@ export class Agent {
275
312
  : Infinity;
276
313
  this.sessionId = config.sessionId || config.cache?.sessionId || createSessionId();
277
314
  (this as any).subagentTraces = {};
278
- this.persistConfig = config.persist;
279
- const rawMode = config.midSession?.mode ?? "auto";
280
- const normalizedMode = rawMode === "steer" || rawMode === "queue" ? rawMode : "auto";
315
+ this.home = resolveHome(config.home);
316
+ // `persist: {}` (neither dir nor file) means the default sessions folder under home.
317
+ this.persistConfig =
318
+ config.persist && !config.persist.dir && !config.persist.file
319
+ ? { ...config.persist, dir: path.join(this.home, "sessions") }
320
+ : config.persist;
321
+ // `whenBusy` (canonical) wins over legacy `mode`; anything else is "auto".
322
+ const normalizedMode = resolveWhenBusy(config.midSession);
281
323
  const rawMaxQueued = (config.midSession as any)?.maxQueued;
282
324
  const rawMaxSteers = (config.midSession as any)?.maxSteers;
283
325
  this.midSessionConfig = {
@@ -298,6 +340,12 @@ export class Agent {
298
340
  const mpThinking = (rawModel as any)?.thinkingLevel;
299
341
  this.thinkingConfig = normalizeThinking(config, mpThinking);
300
342
 
343
+ // Structured outputs: validate early so invalid schemas fail fast at construction.
344
+ if (config.output !== undefined && config.output !== null) {
345
+ this.output = config.output;
346
+ this.outputSpec = normalizeStructuredOutput(config.output);
347
+ }
348
+
301
349
  // DX5: cache retention short|medium|long, undefined = no explicit
302
350
  this.cacheConfig = {
303
351
  sessionId: this.sessionId,
@@ -308,23 +356,40 @@ export class Agent {
308
356
 
309
357
  this.serviceTier = config.serviceTier;
310
358
  this.bypassInputFileModality = config.bypassInputFileModality ?? false;
359
+ this.onFailure =
360
+ config.onFailure === "rollback" || config.onFailure === "keep" ? config.onFailure : "auto";
361
+ this.forkConfig = { ...config };
362
+ this.captureRaw = config.captureRaw;
363
+ this.budget = config.budget ? { ...config.budget } : undefined;
364
+ for (const key of ["hooks", "contextManagement", "logger"] as const) {
365
+ if (config[key] !== undefined) {
366
+ // Declared for forward compatibility only: say so instead of silently ignoring.
367
+ telemetryBus.emitWarning(
368
+ "config.unsupported_option",
369
+ `[Agent Accelerator] AgentConfig.${key} is accepted for forward compatibility but is not implemented yet; it has no effect. Attach to telemetryBus.onEvent for observability.`,
370
+ { option: key }
371
+ );
372
+ }
373
+ }
374
+ if (config.telemetry?.persist) persistTelemetry(this.home);
375
+ this.googleTier = config.googleTier === "paid" || config.googleTier === "free" ? config.googleTier : undefined;
376
+ this.callDefaults = {
377
+ toolChoice: config.toolChoice,
378
+ maxOutputTokens: config.maxOutputTokens,
379
+ stop: config.stop,
380
+ seed: config.seed,
381
+ retry: config.retry,
382
+ env: config.env,
383
+ fetch: config.fetch,
384
+ };
385
+ this.fileAccess = config.fileAccess
386
+ ? { ...(config.fileAccess.roots ? { roots: [...config.fileAccess.roots] } : {}) }
387
+ : undefined;
311
388
  this.stateless = config.stateless ?? false;
312
389
 
313
- // Tools registration
390
+ // Tools registration (unified normalization shared with dynamic pool).
314
391
  if (config.tools) {
315
- if (Array.isArray(config.tools)) {
316
- for (const t of config.tools) {
317
- const tName = t.name || `tool_${Object.keys(this.tools).length}`;
318
- this.tools[tName] = { ...t, name: tName };
319
- }
320
- } else {
321
- for (const [key, def] of Object.entries(config.tools)) {
322
- this.tools[key] = {
323
- ...def,
324
- name: def.name || key,
325
- };
326
- }
327
- }
392
+ Object.assign(this.tools, normalizeToolMap(config.tools));
328
393
  }
329
394
 
330
395
 
@@ -336,23 +401,34 @@ export class Agent {
336
401
  }
337
402
 
338
403
  if (this.bypassInputFileModality && !this.tools["convert_document_to_markdown"]) {
339
- this.tools["convert_document_to_markdown"] = convert_document_to_markdown;
404
+ this.tools["convert_document_to_markdown"] = createConvertDocumentTool({ roots: this.fileAccess?.roots });
340
405
  }
341
406
 
342
- if (this.dynamicSubagents?.enabled === true) {
343
- if (!this.subagentModel) {
344
- throw new SubAgentModelError(
345
- typeof this.modelStringOrSpec === "string" ? this.modelStringOrSpec : (this.modelStringOrSpec as any)?.id
346
- );
347
- }
407
+ const dyn = this.dynamicSubagents;
408
+ if ((dyn?.enabled === true || dyn?.allowScheduledActions) && !this.subagentModel) {
409
+ throw new SubAgentModelError(
410
+ typeof this.modelStringOrSpec === "string" ? this.modelStringOrSpec : (this.modelStringOrSpec as any)?.id
411
+ );
412
+ }
413
+ if (dyn?.enabled === true) {
348
414
  const spawnTool = createSubagentSpawnTool(this);
349
415
  this.tools[spawnTool.name || "spawn_subagents"] = spawnTool;
350
416
  }
417
+ if (dyn?.allowScheduledActions) {
418
+ // Fixed by config, so the tool set (and the prompt cache) stays stable.
419
+ const scheduleTool = createScheduledActionsTool(this);
420
+ this.tools[scheduleTool.name || "scheduled_actions"] = scheduleTool;
421
+ if (!this.tools["get_datetime"]) this.tools["get_datetime"] = getDatetimeTool;
422
+ }
423
+
424
+ // Ambiguous tool names would only blow up mid-run, after a paid model turn: fail at construction.
425
+ assertNoToolCollisions(this.tools);
351
426
 
352
427
  this.context = new AgentContext(this.getFullInstructions());
353
428
  if (initialCachedId) {
354
429
  this.context.cachedContentId = initialCachedId;
355
430
  }
431
+ if (dyn?.allowScheduledActions) registerAgent(this);
356
432
  }
357
433
 
358
434
  private getFullInstructions(): string | undefined {
@@ -594,12 +670,39 @@ export class Agent {
594
670
  }
595
671
  const active = this.currentRun;
596
672
  if (!active) return this.run(prompt, options) as Promise<AgentResponse>;
673
+ return this.steerInto(active, prompt, options);
674
+ }
675
+
676
+ /**
677
+ * Buffers a steer prompt into the active run and returns a promise for ITS
678
+ * reply: the active run's response when the prompt is injected, or (if the
679
+ * run ends first and the prompt becomes a queued follow-up) the follow-up
680
+ * run's response, so a late steer's answer is never lost.
681
+ */
682
+ private steerInto(
683
+ active: NonNullable<Agent["currentRun"]>,
684
+ prompt: string | ContentPart[],
685
+ options?: AgentRunOptions
686
+ ): Promise<AgentResponse> {
597
687
  this.assertSteerRoom(active.steerInbox);
598
- active.steerInbox.push({ prompt, options });
688
+ const runDone = active.runPromise ?? active.outerStream?.result();
689
+ if (!runDone) return this.run(prompt, options) as Promise<AgentResponse>;
690
+ const entry: SteerEntry = { prompt, options };
691
+ const reply = new Promise<AgentResponse>((resolve, reject) => {
692
+ entry.settle = { resolve, reject };
693
+ });
694
+ reply.catch(() => {}); // a fire-and-forget caller must not trigger an unhandled rejection
695
+ active.steerInbox.push(entry);
599
696
  this.persistNow();
600
- if (active.runPromise) return active.runPromise;
601
- if (active.outerStream) return active.outerStream.result();
602
- return this.run(prompt, options) as Promise<AgentResponse>;
697
+ runDone.then(
698
+ (res) => {
699
+ if (!entry.settle?.deferredToQueue) entry.settle?.resolve(res);
700
+ },
701
+ (err) => {
702
+ if (!entry.settle?.deferredToQueue) entry.settle?.reject(err);
703
+ }
704
+ );
705
+ return reply;
603
706
  }
604
707
 
605
708
  /**
@@ -706,15 +809,24 @@ export class Agent {
706
809
  const max = this.midSessionConfig.maxQueued ?? 20;
707
810
  for (const entry of leftover) {
708
811
  try {
709
- if ((entry.options as { signal?: AbortSignal } | undefined)?.signal?.aborted) continue;
812
+ if ((entry.options as { signal?: AbortSignal } | undefined)?.signal?.aborted) {
813
+ entry.settle?.reject(Object.assign(new Error("Steer aborted before it could run"), { name: "AbortError" }));
814
+ continue;
815
+ }
710
816
  } catch { continue; }
711
- if (this.pendingQueue.length >= max) break;
817
+ if (this.pendingQueue.length >= max) {
818
+ entry.settle?.reject(new Error("[Agent Accelerator] Steer dropped: the follow-up queue is full."));
819
+ continue;
820
+ }
821
+ // The steer was never injected: it now runs as a follow-up, and the
822
+ // caller's promise resolves with that run's response.
823
+ if (entry.settle) entry.settle.deferredToQueue = true;
712
824
  this.pendingQueue.push({
713
825
  prompt: entry.prompt,
714
826
  options: entry.options,
715
827
  isStream: false,
716
- resolve: () => {},
717
- reject: () => {},
828
+ resolve: entry.settle?.resolve ?? (() => {}),
829
+ reject: entry.settle?.reject ?? (() => {}),
718
830
  });
719
831
  }
720
832
  } catch {}
@@ -768,14 +880,116 @@ export class Agent {
768
880
  return runController.signal;
769
881
  }
770
882
 
883
+ /**
884
+ * Config a spawned dynamic worker must inherit so it is never less
885
+ * constrained than its parent (run limits, retry policy, credentials/env,
886
+ * fetch, Google tier, file-access policy).
887
+ * @internal
888
+ */
889
+ inheritableConfig(): Partial<AgentConfig> {
890
+ const d = this.callDefaults;
891
+ return {
892
+ limits: { ...this.runLimits },
893
+ ...(d.retry ? { retry: d.retry } : {}),
894
+ ...(d.env ? { env: d.env } : {}),
895
+ ...(d.fetch ? { fetch: d.fetch } : {}),
896
+ ...(this.googleTier ? { googleTier: this.googleTier } : {}),
897
+ ...(this.fileAccess ? { fileAccess: this.fileAccess } : {}),
898
+ ...(this.captureRaw !== undefined ? { captureRaw: this.captureRaw } : {}),
899
+ };
900
+ }
901
+
902
+ /**
903
+ * Isolated, stateless copy of this agent for ONE delegated call. An `Agent`
904
+ * runs one thing at a time (a second `stream()` while busy queues or steers
905
+ * into the first), so delegation tools fork per call: parallel calls to the
906
+ * same worker then truly run in parallel, each with its own context and
907
+ * session id, and never see each other's answers. The copy keeps this
908
+ * agent's model, instructions, tools and policies but not its persistence.
909
+ * @internal
910
+ */
911
+ fork(sessionId: string, parentSessionId?: string): Agent {
912
+ // A fork is a short-lived worker copy: it must not schedule, nor replace the
913
+ // real agent in the scheduler's registry.
914
+ const dyn = this.forkConfig.dynamicSubagents;
915
+ const copy = new Agent({
916
+ ...this.forkConfig,
917
+ sessionId,
918
+ stateless: true,
919
+ persist: undefined,
920
+ ...(dyn?.allowScheduledActions ? { dynamicSubagents: { ...dyn, allowScheduledActions: false } } : {}),
921
+ });
922
+ if (parentSessionId) (copy as unknown as { parentSessionId?: string }).parentSessionId = parentSessionId;
923
+ return copy;
924
+ }
925
+
926
+ /** Budgets are per run: counters restart whenever a new run begins. */
927
+ private resetBudgetState(): void {
928
+ if (this.budget) this.budgetState = { spawned: 0, totalTokens: 0, totalCostUsd: 0, startedAtMs: nowMs() };
929
+ }
930
+
931
+ /** Loop hook: charge `usage` (when given) and throw `BudgetExceededError` once any cap is passed. */
932
+ private enforceBudget(usage?: TokenUsage): void {
933
+ if (!this.budget) return;
934
+ if (usage) chargeBudgetUsage(this as unknown as Parameters<typeof chargeBudgetUsage>[0], usage);
935
+ checkBudget(this.budget, this.budgetState);
936
+ }
937
+
938
+ /** Folds the canonical per-run `thinking` option into `thinkingLevel` so every downstream reader agrees. */
939
+ private withResolvedThinking<T extends AgentRunOptions>(runOptions: T): T {
940
+ const level = normalizeThinkingV2(runOptions.thinking, runOptions.thinkingLevel);
941
+ return level ? { ...runOptions, thinkingLevel: level } : runOptions;
942
+ }
943
+
944
+ /**
945
+ * Single place that turns agent config + per-run options into the options
946
+ * every provider call receives. Per-run values override agent defaults
947
+ * (objects such as `retry` and `env` merge key by key).
948
+ */
949
+ private buildProviderOptions(runOptions: AgentRunOptions | undefined): ProviderRequestOptions {
950
+ const d = this.callDefaults;
951
+ const sessionId = runOptions?.sessionId || this.sessionId;
952
+ const toolChoice = normalizeToolChoiceOption(runOptions?.toolChoice ?? d.toolChoice);
953
+ const retry = { ...d.retry, ...runOptions?.retry };
954
+ const env = { ...d.env, ...runOptions?.env };
955
+ const stop = normalizeStop(runOptions?.stop ?? d.stop);
956
+ const maxOutputTokens = runOptions?.maxOutputTokens ?? d.maxOutputTokens;
957
+ const seed = runOptions?.seed ?? d.seed;
958
+ const fetchImpl = runOptions?.fetch ?? d.fetch;
959
+ return {
960
+ apiKey: this.apiKey,
961
+ baseUrl: this.baseUrl,
962
+ headers: { ...(this.customHeaders ?? {}), ...(runOptions?.headers ?? {}) },
963
+ thinking: resolveEffectiveThinking(this.thinkingConfig, runOptions?.thinkingLevel),
964
+ cache: this.stateless
965
+ ? { sessionId }
966
+ : { ...this.cacheConfig, sessionId },
967
+ serviceTier: this.serviceTier,
968
+ sessionId,
969
+ ...(this.outputSpec ? { output: this.outputSpec } : {}),
970
+ ...(this.googleTier ? { googleTier: this.googleTier } : {}),
971
+ ...(toolChoice !== undefined ? { toolChoice } : {}),
972
+ ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
973
+ ...(stop ? { stop } : {}),
974
+ ...(seed !== undefined ? { seed } : {}),
975
+ ...(Object.keys(env).length > 0 ? { env } : {}),
976
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
977
+ ...(retry.maxRetries !== undefined ? { maxRetries: retry.maxRetries } : {}),
978
+ ...(retry.maxDelayMs !== undefined ? { maxRetryDelayMs: retry.maxDelayMs } : {}),
979
+ ...(retry.baseDelayMs !== undefined ? { retryBaseDelayMs: retry.baseDelayMs } : {}),
980
+ ...(retry.budgetMs !== undefined ? { retryBudgetMs: retry.budgetMs } : {}),
981
+ };
982
+ }
983
+
771
984
  private startNonStreamingRun(
772
985
  prompt: string | ContentPart[],
773
986
  options?: AgentRunOptions
774
987
  ): Promise<AgentResponse> {
988
+ this.resetBudgetState();
775
989
  const runController = new AbortController();
776
990
  const steerInbox: SteerEntry[] = [];
777
991
  const mergedSignal = this.mergeUserSignal(options?.signal, runController);
778
- const runOptions = { ...options, signal: mergedSignal };
992
+ const runOptions = this.withResolvedThinking({ ...options, signal: mergedSignal });
779
993
  // Q-19a: route to the named session context (default = this.context).
780
994
  const targetContext = this.getSessionContext(runOptions?.sessionId || this.sessionId);
781
995
  const task = (async (): Promise<AgentResponse> => {
@@ -788,17 +1002,7 @@ export class Agent {
788
1002
  this.prepareTurn(prompt, runOptions, targetContext);
789
1003
  this.persistNow();
790
1004
 
791
- const providerOptions = {
792
- apiKey: this.apiKey,
793
- baseUrl: this.baseUrl,
794
- headers: { ...(this.customHeaders ?? {}), ...(runOptions?.headers ?? {}) },
795
- thinking: resolveEffectiveThinking(this.thinkingConfig, runOptions?.thinkingLevel),
796
- cache: this.stateless
797
- ? { sessionId: runOptions?.sessionId || this.sessionId }
798
- : { ...this.cacheConfig, sessionId: runOptions?.sessionId || this.sessionId },
799
- serviceTier: this.serviceTier,
800
- sessionId: runOptions?.sessionId || this.sessionId,
801
- };
1005
+ const providerOptions = this.buildProviderOptions(runOptions);
802
1006
 
803
1007
  const loopConfig = {
804
1008
  agentName: this.name,
@@ -810,11 +1014,16 @@ export class Agent {
810
1014
  runOptions,
811
1015
  maxTurns: this.maxTurns,
812
1016
  bypassInputFileModality: this.bypassInputFileModality,
1017
+ ...(this.fileAccess ? { fileAccess: this.fileAccess } : {}),
1018
+ onFailure: this.onFailure,
1019
+ ...(this.captureRaw !== undefined ? { captureRaw: this.captureRaw } : {}),
1020
+ ...(this.budget ? { enforceBudget: (u?: TokenUsage) => this.enforceBudget(u) } : {}),
813
1021
  onProgress: () => this.persistNow(),
814
1022
  steerInbox,
815
1023
  onSteerInjected: () => this.persistNow(),
816
1024
  // Q-22: loop enforces these (plus local timeout races).
817
1025
  limits: { ...this.runLimits },
1026
+ ...(this.output !== undefined ? { output: this.output } : {}),
818
1027
  };
819
1028
 
820
1029
  const res = await runAgentLoop(loopConfig);
@@ -841,7 +1050,7 @@ export class Agent {
841
1050
  options?: AgentRunOptions
842
1051
  ): AssistantMessageEventStream {
843
1052
  const resolved = resolveModel(this.modelStringOrSpec);
844
- const effectiveLevel = options?.thinkingLevel || this.thinkingConfig?.level;
1053
+ const effectiveLevel = normalizeThinkingV2(options?.thinking, options?.thinkingLevel) || this.thinkingConfig?.level;
845
1054
  if (effectiveLevel) {
846
1055
  validateModelThinking(resolved.provider.id, resolved.modelId, effectiveLevel);
847
1056
  }
@@ -850,22 +1059,13 @@ export class Agent {
850
1059
  this.prepareTurn(prompt, options, targetContext);
851
1060
  this.persistNow();
852
1061
 
1062
+ this.resetBudgetState();
853
1063
  const runController = new AbortController();
854
1064
  const mergedSignal = this.mergeUserSignal(options?.signal, runController);
855
- const runOptions = { ...options, signal: mergedSignal };
1065
+ const runOptions = this.withResolvedThinking({ ...options, signal: mergedSignal });
856
1066
  const steerInbox: SteerEntry[] = [];
857
1067
 
858
- const providerOptions = {
859
- apiKey: this.apiKey,
860
- baseUrl: this.baseUrl,
861
- headers: { ...(this.customHeaders ?? {}), ...(runOptions?.headers ?? {}) },
862
- thinking: resolveEffectiveThinking(this.thinkingConfig, runOptions?.thinkingLevel),
863
- cache: this.stateless
864
- ? { sessionId: runOptions?.sessionId || this.sessionId }
865
- : { ...this.cacheConfig, sessionId: runOptions?.sessionId || this.sessionId },
866
- serviceTier: this.serviceTier,
867
- sessionId: runOptions?.sessionId || this.sessionId,
868
- };
1068
+ const providerOptions = this.buildProviderOptions(runOptions);
869
1069
 
870
1070
  const loopConfig = {
871
1071
  agentName: this.name,
@@ -877,11 +1077,16 @@ export class Agent {
877
1077
  runOptions,
878
1078
  maxTurns: this.maxTurns,
879
1079
  bypassInputFileModality: this.bypassInputFileModality,
1080
+ ...(this.fileAccess ? { fileAccess: this.fileAccess } : {}),
1081
+ onFailure: this.onFailure,
1082
+ ...(this.captureRaw !== undefined ? { captureRaw: this.captureRaw } : {}),
1083
+ ...(this.budget ? { enforceBudget: (u?: TokenUsage) => this.enforceBudget(u) } : {}),
880
1084
  onProgress: () => this.persistNow(),
881
1085
  steerInbox,
882
1086
  onSteerInjected: () => this.persistNow(),
883
1087
  // Q-22: loop enforces these (plus local timeout races).
884
1088
  limits: { ...this.runLimits },
1089
+ ...(this.output !== undefined ? { output: this.output } : {}),
885
1090
  };
886
1091
 
887
1092
  const s = streamAgentLoop(loopConfig);
@@ -921,7 +1126,14 @@ export class Agent {
921
1126
  const hit = own?.[trackingId];
922
1127
  if (hit) return { ...hit, steps: hit.steps.map((s) => ({ ...s })) };
923
1128
  } catch {}
924
- return getSubAgentTrace(trackingId);
1129
+ // Process-wide store, filtered to sessions THIS agent owns: another
1130
+ // agent's workers (e.g. a different tenant) are never reachable.
1131
+ return getSubAgentTraceForSessions(trackingId, this.ownedSessionIds());
1132
+ }
1133
+
1134
+ /** Session ids whose spawned workers this agent may inspect (default + named sessions). */
1135
+ private ownedSessionIds(): ReadonlySet<string> {
1136
+ return new Set<string>([this.sessionId, ...this.sessions.keys()]);
925
1137
  }
926
1138
 
927
1139
  /** Lists TrackingIDs of worker traces visible to this agent (deduped). */
@@ -932,7 +1144,10 @@ export class Agent {
932
1144
  };
933
1145
  const seen = new Set<string>();
934
1146
  const out: string[] = [];
935
- for (const id of [...Object.keys((this as any).subagentTraces ?? {}), ...listSubAgentTraceIds()]) {
1147
+ for (const id of [
1148
+ ...Object.keys((this as any).subagentTraces ?? {}),
1149
+ ...listSubAgentTraceIdsForSessions(this.ownedSessionIds()),
1150
+ ]) {
936
1151
  const raw = rawOf(id);
937
1152
  if (!seen.has(raw)) {
938
1153
  seen.add(raw);
@@ -949,7 +1164,7 @@ export class Agent {
949
1164
  * @example `const off = agent.subscribeToSubAgent(id, (t) => render(t));`
950
1165
  */
951
1166
  subscribeToSubAgent(trackingId: string, fn: (trace: SubAgentTrace) => void): () => void {
952
- return subscribeToSubAgent(trackingId, fn);
1167
+ return subscribeToSubAgentForSessions(trackingId, this.ownedSessionIds(), fn);
953
1168
  }
954
1169
 
955
1170
  /**
@@ -1048,6 +1263,13 @@ export class Agent {
1048
1263
  */
1049
1264
  importSession(data?: PersistedAgentSession | null): void {
1050
1265
  if (!data) return;
1266
+ const badIndex = findInvalidMessage(data.messages ?? []);
1267
+ if (badIndex !== -1) {
1268
+ throw new SessionLoadError(
1269
+ `[Agent Accelerator] Cannot import session: messages[${badIndex}] is not a valid message (expected role system|user|assistant|tool and string or part-array content).`
1270
+ );
1271
+ }
1272
+ this.warnOnLocalMediaPaths(data.messages ?? []);
1051
1273
  const prevSid = this.sessionId;
1052
1274
  if (data.sessionId) (this as any).sessionId = data.sessionId;
1053
1275
  if ((data as any).parentSessionId) (this as any).parentSessionId = (data as any).parentSessionId;
@@ -1107,6 +1329,32 @@ export class Agent {
1107
1329
  try { clearSessionRouting(this.sessionId); } catch {}
1108
1330
  }
1109
1331
 
1332
+ /**
1333
+ * A loaded session may reference local files (`image: "/home/me/x.png"`)
1334
+ * that the next turn would read and upload. Without a `fileAccess.roots`
1335
+ * policy that is silent, so surface it once on the telemetry bus.
1336
+ */
1337
+ private warnOnLocalMediaPaths(messages: ReadonlyArray<{ content?: unknown }>): void {
1338
+ if (this.fileAccess?.roots) return; // enforced every turn by the loop
1339
+ const paths: string[] = [];
1340
+ for (const m of messages) {
1341
+ if (!Array.isArray(m.content)) continue;
1342
+ for (const part of m.content as Array<Record<string, unknown>>) {
1343
+ const key = ["image", "audio", "video", "file"].find((k) => k === part?.type);
1344
+ const value = key ? part[key] : undefined;
1345
+ if (isLocalPathInput(value) && !value.startsWith("media/")) paths.push(value);
1346
+ }
1347
+ }
1348
+ if (paths.length === 0) return;
1349
+ try {
1350
+ telemetryBus.emitWarning(
1351
+ "session.local_media_paths",
1352
+ `[Agent Accelerator] Imported session references ${paths.length} local file path(s) that will be read and sent to the provider on the next turn. Set fileAccess.roots to restrict them.`,
1353
+ { count: paths.length, examples: paths.slice(0, 3) }
1354
+ );
1355
+ } catch {}
1356
+ }
1357
+
1110
1358
  private prepareTurn(prompt: string | ContentPart[], options?: AgentRunOptions, target?: AgentContext): void {
1111
1359
  // Q-20: prompt validation throws synchronously in both run() and
1112
1360
  // stream() (same timing; documented here).
@@ -1203,12 +1451,7 @@ export class Agent {
1203
1451
  const mode = this.resolveEnqueueMode(requested);
1204
1452
  if (mode === "steer") {
1205
1453
  this.assertPromptValid(prompt);
1206
- this.assertSteerRoom(this.currentRun.steerInbox);
1207
- this.currentRun.steerInbox.push({ prompt, options });
1208
- this.persistNow();
1209
- const active = this.currentRun;
1210
- if (active.runPromise) return active.runPromise;
1211
- if (active.outerStream) return active.outerStream.result();
1454
+ return this.steerInto(this.currentRun, prompt, options);
1212
1455
  } else {
1213
1456
  if (this.midSessionConfig.mode === "steer") {
1214
1457
  throw new Error(
@@ -1465,8 +1708,13 @@ export class Agent {
1465
1708
  // <think> open tag then needs its own leading newline, otherwise it
1466
1709
  // glues onto the streamed text mid-line. False at stream start and
1467
1710
  // after every tag (both end with newlines), preserving the exact
1468
- // "<think>\n...\n</think>\n\n" contract for thinking-first streams.
1711
+ // "<think>\n...\n</think>\n" contract for thinking-first streams.
1469
1712
  let needThinkNewline = false;
1713
+ // Set on every think close; consumed by the next text_delta(s) to strip
1714
+ // model-leading blank lines display-only (cache-safe: outer events only).
1715
+ // Stays set until non-empty text is emitted so split "\n" | "\nText"
1716
+ // chunks cannot leak a blank line.
1717
+ let justClosedThink = false;
1470
1718
  const collapseBlankLines = (s: string): string => s.replace(/\n{3,}/g, "\n\n");
1471
1719
  const userOnThinking = options.onThinkingDelta;
1472
1720
  const userOnDelta = options.onDelta;
@@ -1489,15 +1737,34 @@ export class Agent {
1489
1737
  if (isThinking) {
1490
1738
  isThinking = false;
1491
1739
  // Drop any withheld trailing newlines: the span ends cleanly and
1492
- // the tag supplies its own line breaks. Empty spans still close.
1740
+ // the tag supplies its own single line break. Empty spans still close.
1493
1741
  pendingNewlines = "";
1494
- const close = emittedAny ? "\n</think>\n\n" : "</think>\n\n";
1742
+ const close = emittedAny ? "\n</think>\n" : "</think>\n";
1495
1743
  emittedAny = false;
1744
+ justClosedThink = true;
1496
1745
  if (userOnThinking) userOnThinking(close, e);
1497
1746
  else if (userOnDelta) userOnDelta(close, e as any);
1498
1747
  }
1499
1748
  };
1500
1749
 
1750
+ /**
1751
+ * Strips model-leading blank lines after a think close, display-only.
1752
+ * Mutates `e.delta` in place so `for-await` consumers see the same clean
1753
+ * text; safe because loop context derives from the provider result, not
1754
+ * these outer events. Returns the display string (`""` when nothing
1755
+ * displayable yet — caller skips the callback but keeps the flag set so
1756
+ * a split newline-only chunk cannot leak a blank line).
1757
+ */
1758
+ const stripLeadingAfterClose = (e: any): string => {
1759
+ const raw: string = e.delta ?? "";
1760
+ if (!justClosedThink) return raw;
1761
+ const stripped = raw.replace(/^\n+/, "");
1762
+ try { e.delta = stripped; } catch {}
1763
+ if (!stripped) return "";
1764
+ justClosedThink = false;
1765
+ return stripped;
1766
+ };
1767
+
1501
1768
  /** Buffers one raw thinking chunk, returning the display string to
1502
1769
  * emit now (`""` when nothing displayable yet). Mutates the event in
1503
1770
  * place so manual `for-await` consumers see the same clean text; safe
@@ -1535,9 +1802,14 @@ export class Agent {
1535
1802
  });
1536
1803
  s.on("text_delta", (e: any) => {
1537
1804
  closeThink(e);
1538
- needThinkNewline = !((e.delta ?? "").endsWith("\n"));
1539
- if (userOnDelta) userOnDelta(e.delta!, e);
1540
- else if (userOnThinking) userOnThinking(e.delta!, e as any);
1805
+ const display = stripLeadingAfterClose(e);
1806
+ if (!display) {
1807
+ needThinkNewline = false;
1808
+ return;
1809
+ }
1810
+ needThinkNewline = !display.endsWith("\n");
1811
+ if (userOnDelta) userOnDelta(display, e);
1812
+ else if (userOnThinking) userOnThinking(display, e as any);
1541
1813
  });
1542
1814
  // Close thinking tag and reset turn state before tool/subagent execution or results
1543
1815
  s.on("tool_call_start" as any, (e: any) => closeThink(e));
@@ -1555,8 +1827,9 @@ export class Agent {
1555
1827
  if (isThinking) {
1556
1828
  isThinking = false;
1557
1829
  pendingNewlines = "";
1558
- const tag = emittedAny ? "\n</think>\n\n" : "</think>\n\n";
1830
+ const tag = emittedAny ? "\n</think>\n" : "</think>\n";
1559
1831
  emittedAny = false;
1832
+ justClosedThink = true;
1560
1833
  s.push({ type: "thinking_delta", thinkingDelta: tag, __synthetic: true } as any);
1561
1834
  }
1562
1835
  };
@@ -1581,6 +1854,12 @@ export class Agent {
1581
1854
  });
1582
1855
  s.on("text_delta", (e: any) => {
1583
1856
  closeSynthetic();
1857
+ if (justClosedThink) {
1858
+ const stripped: string = (e.delta ?? "").replace(/^\n+/, "");
1859
+ try { e.delta = stripped; } catch {}
1860
+ if (!stripped) return;
1861
+ justClosedThink = false;
1862
+ }
1584
1863
  needThinkNewline = !((e.delta ?? "").endsWith("\n"));
1585
1864
  });
1586
1865
  s.on("tool_call_start" as any, () => {
@@ -1611,7 +1890,16 @@ export class Agent {
1611
1890
  /** Normalizes the public `thinkingLevel` flag into provider-neutral thinking settings. */
1612
1891
  export function normalizeThinking(config: AgentConfig, fallbackLevel?: string): ThinkingConfig | undefined {
1613
1892
  // DX4: only thinkingLevel, values: none, dynamic, minimal, low, medium, high, xhigh, max
1614
- const rawLevel = config.thinkingLevel ?? fallbackLevel;
1893
+ // `thinking` (canonical) wins over legacy `thinkingLevel`; `{ level }` objects are unwrapped.
1894
+ const rawLevel = normalizeThinkingV2(config.thinking, config.thinkingLevel) ?? fallbackLevel;
1895
+ if (typeof config.thinking === "object" && config.thinking?.budgetTokens !== undefined) {
1896
+ // No provider maps an explicit token budget yet: say so instead of ignoring it silently.
1897
+ telemetryBus.emitWarning(
1898
+ "config.thinking_budget_ignored",
1899
+ "[Agent Accelerator] thinking.budgetTokens is not forwarded by any provider adapter yet; the level alone is used.",
1900
+ { budgetTokens: config.thinking.budgetTokens }
1901
+ );
1902
+ }
1615
1903
  const level = rawLevel as ThinkingLevel | undefined;
1616
1904
 
1617
1905
  if (!level) return undefined;
@@ -1639,3 +1927,11 @@ export function normalizeThinking(config: AgentConfig, fallbackLevel?: string):
1639
1927
  }
1640
1928
  return { enabled: true, level };
1641
1929
  }
1930
+
1931
+ /** Maps the public `toolChoice` option onto the provider wire-neutral form. */
1932
+ function normalizeToolChoiceOption(choice: ToolChoiceOption | undefined): ProviderRequestOptions["toolChoice"] {
1933
+ if (!choice) return undefined;
1934
+ if (typeof choice === "string") return choice;
1935
+ if (choice.tool) return { type: "function", function: { name: choice.tool } };
1936
+ return choice.mode;
1937
+ }