agent-accelerator 0.4.5 → 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 (45) 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 +401 -120
  5. package/src/agent/context.ts +11 -16
  6. package/src/agent/delegation.ts +111 -274
  7. package/src/agent/loop.ts +253 -98
  8. package/src/agent/subagent.ts +4 -12
  9. package/src/agent/worker.ts +435 -0
  10. package/src/index.ts +39 -5
  11. package/src/models/catalog-cache.ts +65 -25
  12. package/src/models/catalog.ts +21 -2
  13. package/src/providers/google.ts +282 -74
  14. package/src/providers/openai-compat.ts +61 -41
  15. package/src/providers/openai.ts +46 -31
  16. package/src/providers/openrouter.ts +42 -31
  17. package/src/providers/shared.ts +110 -16
  18. package/src/providers.ts +119 -6
  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 +211 -104
  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 +57 -20
  31. package/src/types/model.ts +18 -0
  32. package/src/types/response.ts +2 -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/errors.ts +11 -0
  38. package/src/utils/headers.ts +15 -15
  39. package/src/utils/home.ts +30 -0
  40. package/src/utils/media.ts +57 -12
  41. package/src/utils/raw-capture.ts +76 -0
  42. package/src/utils/retry.ts +11 -3
  43. package/src/utils/safe-io.ts +252 -0
  44. package/src/utils/session.ts +7 -8
  45. 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,15 +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, normalizeStructuredOutput } 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";
16
38
  import type { StructuredOutputSpec } from "../types/model.ts";
17
39
  import { clearInteractionChains } from "../providers/google.ts";
18
- import { getModel, getSubModel } from "../utils/env.ts";
19
40
  import { validateModelThinking, ensureModelCatalogFresh } from "../models/catalog.ts";
20
41
  import { buildSessionData, type PersistedAgentSession, type SessionTotals,} from "../session/store.ts";
21
42
  import { SessionTelemetry } from "../session/store.ts";
@@ -33,7 +54,7 @@ export function resolveEffectiveThinking(
33
54
  return { enabled: true, level: overrideLevel as ThinkingConfig["level"] };
34
55
  }
35
56
 
36
- import { ConfigError } from "../types/errors.ts";
57
+ import { ConfigError, SessionLoadError } from "../types/errors.ts";
37
58
 
38
59
  /**
39
60
  * Disposers for the manual user-signal forward listeners (Q-21 fallback when
@@ -52,6 +73,7 @@ export interface AgentRunLimits {
52
73
  maxTurns?: number;
53
74
  deadlineMs?: number;
54
75
  maxToolCallsPerRun?: number;
76
+ maxTotalTokens?: number;
55
77
  requestTimeoutMs: number;
56
78
  firstByteTimeoutMs: number;
57
79
  streamIdleTimeoutMs: number;
@@ -68,6 +90,7 @@ function resolveAgentLimits(config: { limits?: Record<string, unknown> }): Agent
68
90
  ...(num(raw.maxToolCallsPerRun) !== undefined
69
91
  ? { maxToolCallsPerRun: Math.max(1, Math.floor(num(raw.maxToolCallsPerRun)!)) }
70
92
  : {}),
93
+ ...(num(raw.maxTotalTokens) !== undefined ? { maxTotalTokens: Math.max(1, Math.floor(num(raw.maxTotalTokens)!)) } : {}),
71
94
  requestTimeoutMs: num(raw.requestTimeoutMs) ?? 300_000,
72
95
  firstByteTimeoutMs: num(raw.firstByteTimeoutMs) ?? 120_000,
73
96
  streamIdleTimeoutMs: num(raw.streamIdleTimeoutMs) ?? 300_000,
@@ -135,7 +158,11 @@ export class Agent {
135
158
  thinkingLevel?: ThinkingLevel;
136
159
  tools: Record<string, ToolDefinition>;
137
160
  timeout: number;
161
+ /** Scheduled actions (`scheduled_actions` + `get_datetime` tools) are on. */
162
+ allowScheduledActions: boolean;
138
163
  };
164
+ /** Home folder for everything written to disk (`sessions/`, `scheduled_actions/`, `telemetry/`). */
165
+ readonly home: string;
139
166
  /** Registered model-callable tools. */
140
167
  readonly tools: Record<string, ToolDefinition> = {};
141
168
  /** Normalized reasoning configuration. */
@@ -150,6 +177,35 @@ export class Agent {
150
177
  readonly serviceTier?: ServiceTier;
151
178
  /** Convert `file` parts client-side for pdf-incapable models. */
152
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
+ };
153
209
  /** Maximum model/tool turns per run. */
154
210
  readonly maxTurns: number;
155
211
  /** Session ID used for history and cache affinity. */
@@ -220,53 +276,29 @@ export class Agent {
220
276
  this.description = config.description || "AI Agent powered by Agent Accelerator";
221
277
  // DX1: only instructions (no systemPrompt alias)
222
278
  this.instructions = config.instructions || "";
223
- const rawModel: any = (config.model as any)?.model ? (config.model as any) : config.model;
224
- // Support ModelProviderInstance {model, apiKey, baseUrl, thinkingLevel} and ModelSpec {id, provider}
225
- if (rawModel && typeof rawModel === "object" && "model" in rawModel) {
226
- this.modelStringOrSpec = rawModel.model;
227
- } else if (rawModel && typeof rawModel === "object" && "id" in rawModel && "provider" in rawModel) {
228
- this.modelStringOrSpec = rawModel;
229
- } else {
230
- this.modelStringOrSpec = rawModel || getModel();
231
- }
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;
232
283
  const dynRaw = config.dynamicSubagents;
233
- const dynModelRaw = (dynRaw as any)?.model ?? config.subagentModel;
234
- let dynModel: string | any | undefined;
235
- if (dynModelRaw && typeof dynModelRaw === "object" && "model" in dynModelRaw) {
236
- dynModel = (dynModelRaw as any).model;
237
- } else if (dynModelRaw && typeof dynModelRaw === "object" && "id" in dynModelRaw && "provider" in dynModelRaw) {
238
- dynModel = dynModelRaw;
239
- } else {
240
- dynModel = (dynModelRaw as any) || getSubModel();
241
- }
242
- 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;
243
287
  const dynEnabled = dynRaw ? (dynRaw.enabled ?? true) : false;
244
288
  if (dynRaw) {
245
- const rawTools = (dynRaw as any)?.tools;
246
- const dynTools: Record<string, ToolDefinition> = {};
247
- if (rawTools) {
248
- if (Array.isArray(rawTools)) {
249
- for (const t of rawTools) {
250
- const tName = (t as any)?.name || `tool_${Object.keys(dynTools).length}`;
251
- dynTools[tName] = { ...(t as any), name: tName };
252
- }
253
- } else {
254
- for (const [key, def] of Object.entries(rawTools as Record<string, ToolDefinition>)) {
255
- dynTools[key] = { ...(def as any), name: (def as any)?.name || key };
256
- }
257
- }
258
- }
289
+ const dynTools = normalizeToolMap((dynRaw as { tools?: Record<string, ToolDefinition> | ToolDefinition[] }).tools);
259
290
  const rawMax = (dynRaw as any)?.maxSpawn;
260
291
  const maxSpawn = Number.isFinite(rawMax) ? Math.max(1, Math.floor(rawMax as number)) : 4;
261
- const rawTimeout = (dynRaw as any)?.timeout;
262
- 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 });
263
294
  this.dynamicSubagents = {
264
295
  enabled: dynEnabled,
265
- model: dynModel,
296
+ model: dynModel as string | any | undefined,
266
297
  maxSpawn,
267
298
  thinkingLevel: (dynRaw as any)?.thinkingLevel,
268
299
  tools: dynTools,
269
300
  timeout,
301
+ allowScheduledActions: dynRaw.allowScheduledActions === true,
270
302
  };
271
303
  }
272
304
  this.apiKey = (rawModel as any)?.apiKey || config.apiKey;
@@ -280,9 +312,14 @@ export class Agent {
280
312
  : Infinity;
281
313
  this.sessionId = config.sessionId || config.cache?.sessionId || createSessionId();
282
314
  (this as any).subagentTraces = {};
283
- this.persistConfig = config.persist;
284
- const rawMode = config.midSession?.mode ?? "auto";
285
- 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);
286
323
  const rawMaxQueued = (config.midSession as any)?.maxQueued;
287
324
  const rawMaxSteers = (config.midSession as any)?.maxSteers;
288
325
  this.midSessionConfig = {
@@ -319,23 +356,40 @@ export class Agent {
319
356
 
320
357
  this.serviceTier = config.serviceTier;
321
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;
322
388
  this.stateless = config.stateless ?? false;
323
389
 
324
- // Tools registration
390
+ // Tools registration (unified normalization shared with dynamic pool).
325
391
  if (config.tools) {
326
- if (Array.isArray(config.tools)) {
327
- for (const t of config.tools) {
328
- const tName = t.name || `tool_${Object.keys(this.tools).length}`;
329
- this.tools[tName] = { ...t, name: tName };
330
- }
331
- } else {
332
- for (const [key, def] of Object.entries(config.tools)) {
333
- this.tools[key] = {
334
- ...def,
335
- name: def.name || key,
336
- };
337
- }
338
- }
392
+ Object.assign(this.tools, normalizeToolMap(config.tools));
339
393
  }
340
394
 
341
395
 
@@ -347,23 +401,34 @@ export class Agent {
347
401
  }
348
402
 
349
403
  if (this.bypassInputFileModality && !this.tools["convert_document_to_markdown"]) {
350
- this.tools["convert_document_to_markdown"] = convert_document_to_markdown;
404
+ this.tools["convert_document_to_markdown"] = createConvertDocumentTool({ roots: this.fileAccess?.roots });
351
405
  }
352
406
 
353
- if (this.dynamicSubagents?.enabled === true) {
354
- if (!this.subagentModel) {
355
- throw new SubAgentModelError(
356
- typeof this.modelStringOrSpec === "string" ? this.modelStringOrSpec : (this.modelStringOrSpec as any)?.id
357
- );
358
- }
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) {
359
414
  const spawnTool = createSubagentSpawnTool(this);
360
415
  this.tools[spawnTool.name || "spawn_subagents"] = spawnTool;
361
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);
362
426
 
363
427
  this.context = new AgentContext(this.getFullInstructions());
364
428
  if (initialCachedId) {
365
429
  this.context.cachedContentId = initialCachedId;
366
430
  }
431
+ if (dyn?.allowScheduledActions) registerAgent(this);
367
432
  }
368
433
 
369
434
  private getFullInstructions(): string | undefined {
@@ -605,12 +670,39 @@ export class Agent {
605
670
  }
606
671
  const active = this.currentRun;
607
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> {
608
687
  this.assertSteerRoom(active.steerInbox);
609
- 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);
610
696
  this.persistNow();
611
- if (active.runPromise) return active.runPromise;
612
- if (active.outerStream) return active.outerStream.result();
613
- 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;
614
706
  }
615
707
 
616
708
  /**
@@ -717,15 +809,24 @@ export class Agent {
717
809
  const max = this.midSessionConfig.maxQueued ?? 20;
718
810
  for (const entry of leftover) {
719
811
  try {
720
- 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
+ }
721
816
  } catch { continue; }
722
- 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;
723
824
  this.pendingQueue.push({
724
825
  prompt: entry.prompt,
725
826
  options: entry.options,
726
827
  isStream: false,
727
- resolve: () => {},
728
- reject: () => {},
828
+ resolve: entry.settle?.resolve ?? (() => {}),
829
+ reject: entry.settle?.reject ?? (() => {}),
729
830
  });
730
831
  }
731
832
  } catch {}
@@ -779,14 +880,116 @@ export class Agent {
779
880
  return runController.signal;
780
881
  }
781
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
+
782
984
  private startNonStreamingRun(
783
985
  prompt: string | ContentPart[],
784
986
  options?: AgentRunOptions
785
987
  ): Promise<AgentResponse> {
988
+ this.resetBudgetState();
786
989
  const runController = new AbortController();
787
990
  const steerInbox: SteerEntry[] = [];
788
991
  const mergedSignal = this.mergeUserSignal(options?.signal, runController);
789
- const runOptions = { ...options, signal: mergedSignal };
992
+ const runOptions = this.withResolvedThinking({ ...options, signal: mergedSignal });
790
993
  // Q-19a: route to the named session context (default = this.context).
791
994
  const targetContext = this.getSessionContext(runOptions?.sessionId || this.sessionId);
792
995
  const task = (async (): Promise<AgentResponse> => {
@@ -799,18 +1002,7 @@ export class Agent {
799
1002
  this.prepareTurn(prompt, runOptions, targetContext);
800
1003
  this.persistNow();
801
1004
 
802
- const providerOptions = {
803
- apiKey: this.apiKey,
804
- baseUrl: this.baseUrl,
805
- headers: { ...(this.customHeaders ?? {}), ...(runOptions?.headers ?? {}) },
806
- thinking: resolveEffectiveThinking(this.thinkingConfig, runOptions?.thinkingLevel),
807
- cache: this.stateless
808
- ? { sessionId: runOptions?.sessionId || this.sessionId }
809
- : { ...this.cacheConfig, sessionId: runOptions?.sessionId || this.sessionId },
810
- serviceTier: this.serviceTier,
811
- sessionId: runOptions?.sessionId || this.sessionId,
812
- ...(this.outputSpec ? { output: this.outputSpec } : {}),
813
- };
1005
+ const providerOptions = this.buildProviderOptions(runOptions);
814
1006
 
815
1007
  const loopConfig = {
816
1008
  agentName: this.name,
@@ -822,6 +1014,10 @@ export class Agent {
822
1014
  runOptions,
823
1015
  maxTurns: this.maxTurns,
824
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) } : {}),
825
1021
  onProgress: () => this.persistNow(),
826
1022
  steerInbox,
827
1023
  onSteerInjected: () => this.persistNow(),
@@ -854,7 +1050,7 @@ export class Agent {
854
1050
  options?: AgentRunOptions
855
1051
  ): AssistantMessageEventStream {
856
1052
  const resolved = resolveModel(this.modelStringOrSpec);
857
- const effectiveLevel = options?.thinkingLevel || this.thinkingConfig?.level;
1053
+ const effectiveLevel = normalizeThinkingV2(options?.thinking, options?.thinkingLevel) || this.thinkingConfig?.level;
858
1054
  if (effectiveLevel) {
859
1055
  validateModelThinking(resolved.provider.id, resolved.modelId, effectiveLevel);
860
1056
  }
@@ -863,23 +1059,13 @@ export class Agent {
863
1059
  this.prepareTurn(prompt, options, targetContext);
864
1060
  this.persistNow();
865
1061
 
1062
+ this.resetBudgetState();
866
1063
  const runController = new AbortController();
867
1064
  const mergedSignal = this.mergeUserSignal(options?.signal, runController);
868
- const runOptions = { ...options, signal: mergedSignal };
1065
+ const runOptions = this.withResolvedThinking({ ...options, signal: mergedSignal });
869
1066
  const steerInbox: SteerEntry[] = [];
870
1067
 
871
- const providerOptions = {
872
- apiKey: this.apiKey,
873
- baseUrl: this.baseUrl,
874
- headers: { ...(this.customHeaders ?? {}), ...(runOptions?.headers ?? {}) },
875
- thinking: resolveEffectiveThinking(this.thinkingConfig, runOptions?.thinkingLevel),
876
- cache: this.stateless
877
- ? { sessionId: runOptions?.sessionId || this.sessionId }
878
- : { ...this.cacheConfig, sessionId: runOptions?.sessionId || this.sessionId },
879
- serviceTier: this.serviceTier,
880
- sessionId: runOptions?.sessionId || this.sessionId,
881
- ...(this.outputSpec ? { output: this.outputSpec } : {}),
882
- };
1068
+ const providerOptions = this.buildProviderOptions(runOptions);
883
1069
 
884
1070
  const loopConfig = {
885
1071
  agentName: this.name,
@@ -891,6 +1077,10 @@ export class Agent {
891
1077
  runOptions,
892
1078
  maxTurns: this.maxTurns,
893
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) } : {}),
894
1084
  onProgress: () => this.persistNow(),
895
1085
  steerInbox,
896
1086
  onSteerInjected: () => this.persistNow(),
@@ -936,7 +1126,14 @@ export class Agent {
936
1126
  const hit = own?.[trackingId];
937
1127
  if (hit) return { ...hit, steps: hit.steps.map((s) => ({ ...s })) };
938
1128
  } catch {}
939
- 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()]);
940
1137
  }
941
1138
 
942
1139
  /** Lists TrackingIDs of worker traces visible to this agent (deduped). */
@@ -947,7 +1144,10 @@ export class Agent {
947
1144
  };
948
1145
  const seen = new Set<string>();
949
1146
  const out: string[] = [];
950
- 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
+ ]) {
951
1151
  const raw = rawOf(id);
952
1152
  if (!seen.has(raw)) {
953
1153
  seen.add(raw);
@@ -964,7 +1164,7 @@ export class Agent {
964
1164
  * @example `const off = agent.subscribeToSubAgent(id, (t) => render(t));`
965
1165
  */
966
1166
  subscribeToSubAgent(trackingId: string, fn: (trace: SubAgentTrace) => void): () => void {
967
- return subscribeToSubAgent(trackingId, fn);
1167
+ return subscribeToSubAgentForSessions(trackingId, this.ownedSessionIds(), fn);
968
1168
  }
969
1169
 
970
1170
  /**
@@ -1063,6 +1263,13 @@ export class Agent {
1063
1263
  */
1064
1264
  importSession(data?: PersistedAgentSession | null): void {
1065
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 ?? []);
1066
1273
  const prevSid = this.sessionId;
1067
1274
  if (data.sessionId) (this as any).sessionId = data.sessionId;
1068
1275
  if ((data as any).parentSessionId) (this as any).parentSessionId = (data as any).parentSessionId;
@@ -1122,6 +1329,32 @@ export class Agent {
1122
1329
  try { clearSessionRouting(this.sessionId); } catch {}
1123
1330
  }
1124
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
+
1125
1358
  private prepareTurn(prompt: string | ContentPart[], options?: AgentRunOptions, target?: AgentContext): void {
1126
1359
  // Q-20: prompt validation throws synchronously in both run() and
1127
1360
  // stream() (same timing; documented here).
@@ -1218,12 +1451,7 @@ export class Agent {
1218
1451
  const mode = this.resolveEnqueueMode(requested);
1219
1452
  if (mode === "steer") {
1220
1453
  this.assertPromptValid(prompt);
1221
- this.assertSteerRoom(this.currentRun.steerInbox);
1222
- this.currentRun.steerInbox.push({ prompt, options });
1223
- this.persistNow();
1224
- const active = this.currentRun;
1225
- if (active.runPromise) return active.runPromise;
1226
- if (active.outerStream) return active.outerStream.result();
1454
+ return this.steerInto(this.currentRun, prompt, options);
1227
1455
  } else {
1228
1456
  if (this.midSessionConfig.mode === "steer") {
1229
1457
  throw new Error(
@@ -1480,8 +1708,13 @@ export class Agent {
1480
1708
  // <think> open tag then needs its own leading newline, otherwise it
1481
1709
  // glues onto the streamed text mid-line. False at stream start and
1482
1710
  // after every tag (both end with newlines), preserving the exact
1483
- // "<think>\n...\n</think>\n\n" contract for thinking-first streams.
1711
+ // "<think>\n...\n</think>\n" contract for thinking-first streams.
1484
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;
1485
1718
  const collapseBlankLines = (s: string): string => s.replace(/\n{3,}/g, "\n\n");
1486
1719
  const userOnThinking = options.onThinkingDelta;
1487
1720
  const userOnDelta = options.onDelta;
@@ -1504,15 +1737,34 @@ export class Agent {
1504
1737
  if (isThinking) {
1505
1738
  isThinking = false;
1506
1739
  // Drop any withheld trailing newlines: the span ends cleanly and
1507
- // the tag supplies its own line breaks. Empty spans still close.
1740
+ // the tag supplies its own single line break. Empty spans still close.
1508
1741
  pendingNewlines = "";
1509
- const close = emittedAny ? "\n</think>\n\n" : "</think>\n\n";
1742
+ const close = emittedAny ? "\n</think>\n" : "</think>\n";
1510
1743
  emittedAny = false;
1744
+ justClosedThink = true;
1511
1745
  if (userOnThinking) userOnThinking(close, e);
1512
1746
  else if (userOnDelta) userOnDelta(close, e as any);
1513
1747
  }
1514
1748
  };
1515
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
+
1516
1768
  /** Buffers one raw thinking chunk, returning the display string to
1517
1769
  * emit now (`""` when nothing displayable yet). Mutates the event in
1518
1770
  * place so manual `for-await` consumers see the same clean text; safe
@@ -1550,9 +1802,14 @@ export class Agent {
1550
1802
  });
1551
1803
  s.on("text_delta", (e: any) => {
1552
1804
  closeThink(e);
1553
- needThinkNewline = !((e.delta ?? "").endsWith("\n"));
1554
- if (userOnDelta) userOnDelta(e.delta!, e);
1555
- 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);
1556
1813
  });
1557
1814
  // Close thinking tag and reset turn state before tool/subagent execution or results
1558
1815
  s.on("tool_call_start" as any, (e: any) => closeThink(e));
@@ -1570,8 +1827,9 @@ export class Agent {
1570
1827
  if (isThinking) {
1571
1828
  isThinking = false;
1572
1829
  pendingNewlines = "";
1573
- const tag = emittedAny ? "\n</think>\n\n" : "</think>\n\n";
1830
+ const tag = emittedAny ? "\n</think>\n" : "</think>\n";
1574
1831
  emittedAny = false;
1832
+ justClosedThink = true;
1575
1833
  s.push({ type: "thinking_delta", thinkingDelta: tag, __synthetic: true } as any);
1576
1834
  }
1577
1835
  };
@@ -1596,6 +1854,12 @@ export class Agent {
1596
1854
  });
1597
1855
  s.on("text_delta", (e: any) => {
1598
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
+ }
1599
1863
  needThinkNewline = !((e.delta ?? "").endsWith("\n"));
1600
1864
  });
1601
1865
  s.on("tool_call_start" as any, () => {
@@ -1626,7 +1890,16 @@ export class Agent {
1626
1890
  /** Normalizes the public `thinkingLevel` flag into provider-neutral thinking settings. */
1627
1891
  export function normalizeThinking(config: AgentConfig, fallbackLevel?: string): ThinkingConfig | undefined {
1628
1892
  // DX4: only thinkingLevel, values: none, dynamic, minimal, low, medium, high, xhigh, max
1629
- 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
+ }
1630
1903
  const level = rawLevel as ThinkingLevel | undefined;
1631
1904
 
1632
1905
  if (!level) return undefined;
@@ -1654,3 +1927,11 @@ export function normalizeThinking(config: AgentConfig, fallbackLevel?: string):
1654
1927
  }
1655
1928
  return { enabled: true, level };
1656
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
+ }