agent-accelerator 0.5.3 → 0.5.5

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.
@@ -1,7 +1,7 @@
1
- import type { AgentConfig, AgentRunOptions, MidSessionConfig, ToolChoiceOption, RetryPolicyConfig, Budget, BudgetState, InterruptedRun } from "../types/agent.ts";
1
+ import type { AgentConfig, EditableAgentConfig, AgentRunOptions, MidSessionConfig, ToolChoiceOption, RetryPolicyConfig, Budget, BudgetState, InterruptedRun } from "../types/agent.ts";
2
2
  import type { TokenUsage } from "../types/core.ts";
3
3
  import type { ProviderRequestOptions } from "../types/model.ts";
4
- import { normalizeThinkingV2, resolveWhenBusy, normalizeWorkerTimeout } from "../tools/tool.ts";
4
+ import { normalizeThinkingV2, resolveWhenBusy } from "../tools/tool.ts";
5
5
  import type { ToolDefinition, ToolResultRecord } from "../types/tool.ts";
6
6
  import { isSubagentResult } from "../tools/subagent-result.ts";
7
7
  import { CACHE_OPTIONS, type ThinkingLevel, type ThinkingConfig, type CacheOption, type ServiceTier } from "../types/core.ts";
@@ -10,8 +10,11 @@ import { AgentResponse, type StreamEvent } from "../types/response.ts";
10
10
  import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
11
11
  import { AgentContext } from "./context.ts";
12
12
  import { resolveModel } from "../providers/registry.ts";
13
- import { buildAgentTools, createSubagentSpawnTool } from "./delegation.ts";
13
+ import { buildAgentTools } from "./delegation.ts";
14
+ import { createSubagentsTool } from "./subagents-tool.ts";
15
+ import { WorkerRegistry } from "./workers.ts";
14
16
  import {
17
+ DEFAULT_MAX_DEPTH,
15
18
  normalizeToolMap,
16
19
  resolveDynamicModelInput,
17
20
  resolveMainModel,
@@ -22,6 +25,7 @@ import { resolveHome } from "../scheduler/store.ts";
22
25
  import { persistTelemetry } from "../telemetry-file.ts";
23
26
  import { lockFolder } from "../utils/durable.ts";
24
27
  import { createScheduledActionsTool, getDatetimeTool } from "../scheduler/tool.ts";
28
+ import { applyEditable, assertModelAllowed, modelKey, toEditableConfig } from "./config-blocks.ts";
25
29
  import { assertNoToolCollisions } from "../tools/executor.ts";
26
30
  import { runAgentLoop, streamAgentLoop, unfinishedToolCalls, endsWithStop, accumulateUsage, type SteerEntry } from "./loop.ts";
27
31
  import { isRetryableRun, readRetryAfterMs, sleep } from "../utils/retry.ts";
@@ -34,7 +38,8 @@ import {
34
38
  listSubAgentTraceIdsForSessions,
35
39
  type SubAgentTrace,
36
40
  } from "./delegation.ts";
37
- import { saveSessionFile, sessionDirFor, loadSessionDir, findInvalidMessage, telemetryBus, type SessionRunMark, type SessionRunEnd, type SessionPending, type SessionToolRecord } from "../session/store.ts";
41
+ import { addTurn, buildUsageReport, emptyUsage, mergeUsage, modelName, readUsage, type UsageNode, type UsageReport, type UsageTotals } from "../session/usage.ts";
42
+ import { saveSessionFile, sessionDirFor, loadSessionDir, findInvalidMessage, priceUsage, telemetryBus, type SessionRunMark, type SessionRunEnd, type SessionPending, type SessionToolRecord } from "../session/store.ts";
38
43
  import { SessionJournal } from "../session/journal.ts";
39
44
  import { ToolLogs, type ToolLogWriter } from "../session/tool-log.ts";
40
45
  import { isLocalPathInput } from "../utils/safe-io.ts";
@@ -91,6 +96,8 @@ function resolveAgentLimits(config: { limits?: Record<string, unknown> }): Agent
91
96
  const raw = (config as { limits?: Record<string, unknown> }).limits ?? {};
92
97
  const num = (v: unknown): number | undefined =>
93
98
  typeof v === "number" && Number.isFinite(v) ? v : undefined;
99
+ // A network timeout: `0` or `Infinity` turns it off, any other number is milliseconds, anything else is unset (the default applies).
100
+ const timeout = (v: unknown): number | undefined => (typeof v === "number" && !Number.isNaN(v) ? v : undefined);
94
101
  return {
95
102
  ...(num(raw.maxTurns) !== undefined ? { maxTurns: Math.max(1, Math.floor(num(raw.maxTurns)!)) } : {}),
96
103
  ...(num(raw.deadlineMs) !== undefined ? { deadlineMs: num(raw.deadlineMs)! } : {}),
@@ -98,9 +105,9 @@ function resolveAgentLimits(config: { limits?: Record<string, unknown> }): Agent
98
105
  ? { maxToolCallsPerRun: Math.max(1, Math.floor(num(raw.maxToolCallsPerRun)!)) }
99
106
  : {}),
100
107
  ...(num(raw.maxTotalTokens) !== undefined ? { maxTotalTokens: Math.max(1, Math.floor(num(raw.maxTotalTokens)!)) } : {}),
101
- requestTimeoutMs: num(raw.requestTimeoutMs) ?? 300_000,
102
- firstByteTimeoutMs: num(raw.firstByteTimeoutMs) ?? 120_000,
103
- streamIdleTimeoutMs: num(raw.streamIdleTimeoutMs) ?? 300_000,
108
+ requestTimeoutMs: timeout(raw.requestTimeoutMs) ?? 300_000,
109
+ firstByteTimeoutMs: timeout(raw.firstByteTimeoutMs) ?? 120_000,
110
+ streamIdleTimeoutMs: timeout(raw.streamIdleTimeoutMs) ?? 300_000,
104
111
  };
105
112
  }
106
113
 
@@ -161,12 +168,18 @@ export class Agent {
161
168
  readonly dynamicSubagents?: {
162
169
  enabled: boolean;
163
170
  model?: string | any;
171
+ /** Open workers at once; `Infinity` (the default) = no limit. */
164
172
  maxSpawn: number;
165
173
  thinkingLevel?: ThinkingLevel;
166
174
  tools: Record<string, ToolDefinition>;
167
- timeout: number;
175
+ /** Longest a scheduled run may take, in ms; `Infinity` (the default) = no limit. */
176
+ workerTimeoutMs: number;
168
177
  /** Scheduled actions (`scheduled_actions` + `get_datetime` tools) are on. */
169
178
  allowScheduledActions: boolean;
179
+ /** Levels of workers allowed below this agent; `Infinity` (the default) = no limit. */
180
+ maxDepth: number;
181
+ /** Per-run spending cap of each worker (unset = unlimited). */
182
+ workerBudget?: { maxCostUsd?: number; maxTotalTokens?: number };
170
183
  };
171
184
  /** Home folder for everything written to disk (`sessions/`, `scheduled_actions/`, `telemetry/`). */
172
185
  readonly home: string;
@@ -249,6 +262,8 @@ export class Agent {
249
262
  deferredStream?: AssistantMessageEventStream;
250
263
  resolve: (res: AgentResponse) => void;
251
264
  reject: (err: unknown) => void;
265
+ /** Put here by `interrupt(message)`: runs before every other waiting prompt. */
266
+ urgent?: boolean;
252
267
  }> = [];
253
268
  /** Named session contexts for `sessionId` runs (Q-19a, LRU 100 + TTL). */
254
269
  private sessions = new Map<string, { context: AgentContext; lastAccess: number }>();
@@ -263,6 +278,8 @@ export class Agent {
263
278
  readonly runMarks: SessionRunMark[] = [];
264
279
  /** Tool calls finished in the current run; saved the moment each finishes so a crash never repeats them. @internal */
265
280
  readonly toolRecords: SessionToolRecord[] = [];
281
+ /** What this agent spent on its OWN model calls (workers keep their own); saved with the session. @internal */
282
+ readonly usageTotals: UsageTotals = emptyUsage();
266
283
  /** The run in flight (its mark has no ending yet, but it is not "interrupted"). */
267
284
  private activeMark?: SessionRunMark;
268
285
  /** What to do with a cut-off run at construction (see `AgentConfig.resume`). */
@@ -271,6 +288,7 @@ export class Agent {
271
288
  private readonly runRetries: number;
272
289
  /** The resume in flight, so a second `resume()` returns the same promise. */
273
290
  private resuming?: Promise<AgentResponse | undefined>;
291
+ private workerRegistry?: WorkerRegistry;
274
292
 
275
293
  /**
276
294
  * Creates an agent and registers its model, tools, cache, and delegation settings.
@@ -300,6 +318,7 @@ export class Agent {
300
318
  // ModelProviderInstance {model,apiKey,...} and ModelSpec {id,provider}.
301
319
  const { modelStringOrSpec, rawModel } = resolveMainModel(config.model);
302
320
  this.modelStringOrSpec = modelStringOrSpec as string | any;
321
+ assertModelAllowed(config.allowedModels, modelKey(modelStringOrSpec));
303
322
  const dynRaw = config.dynamicSubagents;
304
323
  // Unified worker-model chain: dynamicSubagents.model > subagentModel > SUB_AGENT_MODEL.
305
324
  const dynModel = resolveDynamicModelInput(dynRaw, config.subagentModel);
@@ -307,18 +326,45 @@ export class Agent {
307
326
  const dynEnabled = dynRaw ? (dynRaw.enabled ?? true) : false;
308
327
  if (dynRaw) {
309
328
  const dynTools = normalizeToolMap((dynRaw as { tools?: Record<string, ToolDefinition> | ToolDefinition[] }).tools);
310
- const rawMax = (dynRaw as any)?.maxSpawn;
311
- const maxSpawn = Number.isFinite(rawMax) ? Math.max(1, Math.floor(rawMax as number)) : 4;
312
- // `workerTimeoutMs ?? timeout`, or -1 when `modelMaySetTimeout` (see normalizeWorkerTimeout).
313
- const timeout = normalizeWorkerTimeout(dynRaw as { timeout?: number; workerTimeoutMs?: number; modelMaySetTimeout?: boolean });
329
+ const rawDepth = (dynRaw as { maxDepth?: unknown }).maxDepth;
330
+ // Depth is unlimited unless the developer sets a cap (a whole number >= 1; Infinity also means unlimited).
331
+ if (rawDepth !== undefined && rawDepth !== Infinity && !(Number.isInteger(rawDepth) && (rawDepth as number) >= 1)) {
332
+ throw new ConfigError(`[Agent Accelerator] Invalid dynamicSubagents.maxDepth ${JSON.stringify(rawDepth)}: expected a whole number >= 1, or omit it for no limit; set dynamicSubagents.enabled: false to turn workers off.`);
333
+ }
334
+ const rawBudget = (dynRaw as { workerBudget?: { maxCostUsd?: unknown; maxTotalTokens?: unknown } }).workerBudget;
335
+ for (const key of ["maxCostUsd", "maxTotalTokens"] as const) {
336
+ const v = rawBudget?.[key];
337
+ if (v !== undefined && !(typeof v === "number" && Number.isFinite(v) && v > 0)) {
338
+ throw new ConfigError(`[Agent Accelerator] Invalid dynamicSubagents.workerBudget.${key} ${JSON.stringify(v)}: expected a number greater than 0, or omit it for no limit.`);
339
+ }
340
+ }
341
+ // Open workers are unlimited unless the developer sets a cap (a number >= 1; Infinity also means unlimited).
342
+ const rawMax = (dynRaw as { maxSpawn?: unknown }).maxSpawn;
343
+ if (rawMax !== undefined && (typeof rawMax !== "number" || Number.isNaN(rawMax))) {
344
+ throw new ConfigError(`[Agent Accelerator] Invalid dynamicSubagents.maxSpawn ${JSON.stringify(rawMax)}: expected a whole number >= 1, or omit it for no limit.`);
345
+ }
346
+ const maxSpawn = rawMax === undefined || rawMax === Infinity ? Infinity : Math.max(1, Math.floor(rawMax));
347
+ // Only scheduled actions are timed: no limit unless the developer sets one (0 and Infinity mean none).
348
+ for (const removed of ["timeout", "modelMaySetTimeout"]) {
349
+ if ((dynRaw as Record<string, unknown>)[removed] !== undefined) {
350
+ throw new ConfigError(`[Agent Accelerator] dynamicSubagents.${removed} was removed: set dynamicSubagents.workerTimeoutMs (the longest a scheduled run may take, optional) and let each scheduled action carry its own timeoutMs.`);
351
+ }
352
+ }
353
+ const rawTimeout = (dynRaw as { workerTimeoutMs?: unknown }).workerTimeoutMs;
354
+ if (rawTimeout !== undefined && (typeof rawTimeout !== "number" || Number.isNaN(rawTimeout) || rawTimeout < 0)) {
355
+ throw new ConfigError(`[Agent Accelerator] Invalid dynamicSubagents.workerTimeoutMs ${JSON.stringify(rawTimeout)}: expected a number of milliseconds > 0, or omit it (0 and Infinity mean no limit).`);
356
+ }
357
+ const workerTimeoutMs = !rawTimeout ? Infinity : Math.max(1, Math.floor(rawTimeout));
314
358
  this.dynamicSubagents = {
315
359
  enabled: dynEnabled,
316
360
  model: dynModel as string | any | undefined,
317
361
  maxSpawn,
318
362
  thinkingLevel: (dynRaw as any)?.thinkingLevel,
319
363
  tools: dynTools,
320
- timeout,
364
+ workerTimeoutMs,
321
365
  allowScheduledActions: dynRaw.allowScheduledActions === true,
366
+ maxDepth: (rawDepth as number | undefined) ?? DEFAULT_MAX_DEPTH,
367
+ ...(rawBudget ? { workerBudget: { ...(rawBudget as { maxCostUsd?: number; maxTotalTokens?: number }) } } : {}),
322
368
  };
323
369
  }
324
370
  this.apiKey = (rawModel as any)?.apiKey || config.apiKey;
@@ -438,8 +484,8 @@ export class Agent {
438
484
  );
439
485
  }
440
486
  if (dyn?.enabled === true) {
441
- const spawnTool = createSubagentSpawnTool(this);
442
- this.tools[spawnTool.name || "spawn_subagents"] = spawnTool;
487
+ const subagentsTool = createSubagentsTool(this);
488
+ this.tools[subagentsTool.name || "subagents"] = subagentsTool;
443
489
  }
444
490
  if (dyn?.allowScheduledActions) {
445
491
  // Fixed by config, so the tool set (and the prompt cache) stays stable.
@@ -459,6 +505,11 @@ export class Agent {
459
505
  }
460
506
  }
461
507
 
508
+ /** The persistent sub-agent workers of this agent: `spawn`, `message`, `close`, `list`, `turns` (see `WorkerRegistry`). */
509
+ get workers(): WorkerRegistry {
510
+ return (this.workerRegistry ??= new WorkerRegistry(this));
511
+ }
512
+
462
513
  // Loads a persisted session that a crash cut off (a run without an ending) or that still had waiting prompts, so it can be resumed; true when there is something to resume; in: the session folder of `persist.dir`, out: context, runMarks, toolRecords, pendingQueue.
463
514
  private recoverSession(): boolean {
464
515
  const root = this.persistConfig?.dir;
@@ -475,6 +526,7 @@ export class Agent {
475
526
  if (!(session.runs ?? []).some((r) => !r.end) && waiting.length === 0) return false;
476
527
  this.context.messages = session.messages;
477
528
  this.runMarks.splice(0, this.runMarks.length, ...(session.runs ?? []));
529
+ this.restoreUsage(session.usage);
478
530
  const open = new Set(unfinishedToolCalls(this.context.messages).map((c) => c.id));
479
531
  this.toolRecords.splice(0, this.toolRecords.length, ...(session.tools ?? []).filter((t) => open.has(t.id)));
480
532
  for (const w of waiting) {
@@ -489,6 +541,42 @@ export class Agent {
489
541
  return true;
490
542
  }
491
543
 
544
+ // Puts saved spending totals back after a restart or import (zeros when the file has none); in: a loaded session's `usage`, out: this.usageTotals.
545
+ private restoreUsage(saved: unknown): void {
546
+ Object.assign(this.usageTotals, readUsage(saved));
547
+ }
548
+
549
+ // Records one model call of THIS agent (priced per turn, with the service tier); in: the loop's onTurnUsage, out: this.usageTotals (saved by the next persist).
550
+ private recordTurnUsage(usage: TokenUsage, spec: Parameters<typeof priceUsage>[1]): void {
551
+ addTurn(this.usageTotals, usage, priceUsage(usage, spec, this.serviceTier ? { serviceTier: this.serviceTier } : undefined));
552
+ }
553
+
554
+ /** Adds another agent's spending to this one (a fixed worker's per-call copy into its template). @internal */
555
+ absorbUsage(other: UsageTotals): void {
556
+ mergeUsage(this.usageTotals, other);
557
+ }
558
+
559
+ /**
560
+ * What this agent and every worker below it spent: per agent and per worker (tokens, cache hits, thinking, exact
561
+ * cost per turn) plus a whole-session total, with nothing counted twice. `agent` is this agent's own model calls;
562
+ * workers (open, closed, nested, and rebuilt from disk after a restart) and fixed `subagents` workers are listed
563
+ * below it. Spending of one-shot scheduled-action workers is in their run files, not here.
564
+ */
565
+ usageReport(): UsageReport {
566
+ return buildUsageReport({ id: this.sessionId, name: this.name, model: modelName(this.modelStringOrSpec), totals: this.usageTotals }, [...this.workers.usageNodes(), ...this.fixedWorkerNodes()]);
567
+ }
568
+
569
+ // The `subagents: [...]` workers as usage nodes (their per-call copies' spending is merged into them); in: the constructor config, out: usageReport.
570
+ private fixedWorkerNodes(): UsageNode[] {
571
+ const nodes: UsageNode[] = [];
572
+ for (const entry of this.forkConfig.subagents ?? []) {
573
+ const worker = "agent" in entry ? entry.agent : entry;
574
+ const name = "agent" in entry ? entry.name : worker.name;
575
+ nodes.push({ id: name, name, model: modelName(worker.modelStringOrSpec), status: "fixed", totals: worker.usageTotals, workers: [] });
576
+ }
577
+ return nodes;
578
+ }
579
+
492
580
  // Says on the telemetry bus that resuming (or a prompt that was waiting) failed; in: a rejected resume, out: telemetryBus.
493
581
  private reportResumeFailure(err: unknown): void {
494
582
  telemetryBus.emitWarning("agent.resume_failed", `[Agent Accelerator] Resuming session ${this.sessionId} failed: ${err instanceof Error ? err.message : String(err)}`, { sessionId: this.sessionId });
@@ -579,26 +667,6 @@ export class Agent {
579
667
  "3. Once your reasoning is complete, output your final response or function calls directly in the standard response output.";
580
668
  out = out ? `${out}\n\n${guidance}` : guidance;
581
669
  }
582
- if (this.dynamicSubagents?.enabled === true && !out.includes("[Dynamic Sub-Agents]")) {
583
- const dyn = this.dynamicSubagents;
584
- const toolNames = Object.keys(dyn.tools);
585
- const timeoutNote =
586
- dyn.timeout === -1
587
- ? "Timeout policy: set a per-sub-agent timeoutMs (ms) for each task; omit it for no limit."
588
- : dyn.timeout === 0
589
- ? "Timeout policy: workers run with no time limit."
590
- : `Timeout policy: every worker is limited to ${dyn.timeout}ms; per-task timeouts are ignored.`;
591
- const policy =
592
- "[Dynamic Sub-Agents]\n" +
593
- `1. You may spawn at most ${dyn.maxSpawn} sub-agent(s) per spawn_subagents call. Extra tasks beyond ${dyn.maxSpawn} are ignored.\n` +
594
- "2. Workers are stateless: each receives one task, returns its result, then shuts down. No conversation history is kept.\n" +
595
- "3. You cannot choose worker models or reasoning levels — they are fixed by the developer.\n" +
596
- (toolNames.length > 0
597
- ? `4. Worker-available tools: ${toolNames.join(", ")}. Grant each worker ONLY the tools its task needs via the per-task tools list; omit it for no tools.\n`
598
- : "4. No worker tools are available; omit the per-task tools list.\n") +
599
- `5. ${timeoutNote}`;
600
- out = out ? `${out}\n\n${policy}` : policy;
601
- }
602
670
  return out || undefined;
603
671
  }
604
672
 
@@ -759,22 +827,46 @@ export class Agent {
759
827
  }
760
828
 
761
829
  /**
762
- * Interrupts the active run immediately (STOP. Do this instead).
763
- * Cancels the active stream and aborts provider/tool/sub-agent work via
764
- * the run's internal `AbortController`. No-op when idle. The interrupted
765
- * run rejects with `AbortError`; queued follow-ups still run afterwards.
830
+ * Stops the active run now (STOP.) and, given a message, starts it next (STOP. Do this instead). The provider
831
+ * request is aborted (connection closed, so the provider stops generating where it can), the stream is cancelled, and
832
+ * running tools and workers get the abort signal; the interrupted run rejects with `AbortError` and keeps its partial
833
+ * history. `interrupt()` and `interrupt(error)` only stop (no-op when idle) and return nothing. `interrupt(message)`
834
+ * also runs `message` as the next run, ahead of queued follow-ups, in every `midSession` mode, and resolves with that
835
+ * run's response; when idle it simply runs the message. For a stream use `stream(message, { enqueue: "interrupt" })`.
766
836
  */
767
- interrupt(reason?: unknown): void {
837
+ interrupt(reason?: Error): void;
838
+ interrupt(message: string | ContentPart[], options?: AgentRunOptions): Promise<AgentResponse>;
839
+ interrupt(arg?: Error | string | ContentPart[], options?: AgentRunOptions): void | Promise<AgentResponse> {
840
+ if (typeof arg === "string" || Array.isArray(arg)) {
841
+ if (options?.stream === true) {
842
+ throw new Error('[Agent Accelerator] interrupt(message) is non-streaming. Use stream(prompt, { enqueue: "interrupt" }) for a streaming interrupt.');
843
+ }
844
+ return this.run(arg, { ...options, enqueue: "interrupt" });
845
+ }
846
+ this.abortActive(arg instanceof Error ? arg : undefined);
847
+ }
848
+
849
+ // Aborts the active run (provider request, tools, workers, stream) through its own controller; no-op when idle; in: interrupt and queueUrgent, out: the run settles as AbortError.
850
+ private abortActive(reason?: Error): void {
768
851
  const active = this.currentRun;
769
852
  if (!active) return;
770
853
  try {
771
- active.abortController.abort(reason instanceof Error ? reason : new Error("Agent interrupted"));
854
+ active.abortController.abort(reason ?? new Error("Agent interrupted"));
772
855
  } catch {}
773
856
  try {
774
857
  active.outerStream?.cancel(reason);
775
858
  } catch {}
776
859
  }
777
860
 
861
+ // Puts a prompt ahead of everything waiting (behind earlier interrupts) and aborts the active run, so it starts as soon as that run has settled; ignores midSession mode and maxQueued; in: run()/stream() with enqueue "interrupt", out: pendingQueue + the abort.
862
+ private queueUrgent(entry: Agent["pendingQueue"][number]): void {
863
+ entry.urgent = true;
864
+ const firstOrdinary = this.pendingQueue.findIndex((e) => !e.urgent);
865
+ this.pendingQueue.splice(firstOrdinary < 0 ? this.pendingQueue.length : firstOrdinary, 0, entry);
866
+ this.persistNow();
867
+ this.abortActive();
868
+ }
869
+
778
870
  /**
779
871
  * Steers the active turn (While you're doing that, change direction).
780
872
  * The current provider/tool work finishes first, then `prompt` is injected
@@ -963,7 +1055,9 @@ export class Agent {
963
1055
  reject: entry.settle?.reject ?? (() => {}),
964
1056
  });
965
1057
  }
966
- this.pendingQueue.unshift(...lateSteers);
1058
+ // ...but behind an interrupt(message), which is the newest and most urgent instruction.
1059
+ const firstOrdinary = this.pendingQueue.findIndex((e) => !e.urgent);
1060
+ this.pendingQueue.splice(firstOrdinary < 0 ? this.pendingQueue.length : firstOrdinary, 0, ...lateSteers);
967
1061
  } catch {}
968
1062
  }
969
1063
  this.persistNow();
@@ -1059,6 +1153,24 @@ export class Agent {
1059
1153
  return copy;
1060
1154
  }
1061
1155
 
1156
+ /**
1157
+ * The part of this agent that an agent may edit, as plain JSON: `{ model, instructions, tools }` (`model` is the
1158
+ * `provider/model` string, never a key; `tools` are the names of the tools you passed in `tools`). Store it, show it
1159
+ * to the model, and rebuild with {@link Agent.fromConfig}.
1160
+ */
1161
+ toConfig(): Required<EditableAgentConfig> {
1162
+ return toEditableConfig(this.modelStringOrSpec, this.instructions, this.forkConfig.tools);
1163
+ }
1164
+
1165
+ /**
1166
+ * Builds an Agent from your full `base` config with an agent's edits applied. Only `model`, `instructions` and
1167
+ * `tools` of `editable` count (other keys are ignored with a `config.not_editable` warning); `tools` are names picked
1168
+ * from `base.tools`, an unknown name throws; `base.allowedModels` is enforced. Keys and limits always come from `base`.
1169
+ */
1170
+ static fromConfig(editable: EditableAgentConfig, base: AgentConfig): Agent {
1171
+ return new Agent(applyEditable(editable, base));
1172
+ }
1173
+
1062
1174
  /** Budgets are per run: counters restart whenever a new run begins. */
1063
1175
  private resetBudgetState(): void {
1064
1176
  if (this.budget) this.budgetState = { spawned: 0, totalTokens: 0, totalCostUsd: 0, startedAtMs: nowMs() };
@@ -1161,6 +1273,7 @@ export class Agent {
1161
1273
  ...(this.captureRaw !== undefined ? { captureRaw: this.captureRaw } : {}),
1162
1274
  ...(this.budget ? { enforceBudget: (u?: TokenUsage) => this.enforceBudget(u) } : {}),
1163
1275
  onProgress: () => this.persistNow(),
1276
+ onTurnUsage: (u: TokenUsage, spec: Parameters<typeof priceUsage>[1]) => this.recordTurnUsage(u, spec),
1164
1277
  onToolResult: (r: ToolResultRecord) => this.recordTool(r),
1165
1278
  openToolLog: (call: { name: string }, sid: string | undefined) => this.openToolLog(call, sid),
1166
1279
  ...(resumeMark ? { resume: { stored: this.storedToolResults() } } : {}),
@@ -1251,6 +1364,7 @@ export class Agent {
1251
1364
  ...(this.captureRaw !== undefined ? { captureRaw: this.captureRaw } : {}),
1252
1365
  ...(this.budget ? { enforceBudget: (u?: TokenUsage) => this.enforceBudget(u) } : {}),
1253
1366
  onProgress: () => this.persistNow(),
1367
+ onTurnUsage: (u: TokenUsage, spec: Parameters<typeof priceUsage>[1]) => this.recordTurnUsage(u, spec),
1254
1368
  onToolResult: (r: ToolResultRecord) => this.recordTool(r),
1255
1369
  openToolLog: (call: { name: string }, sid: string | undefined) => this.openToolLog(call, sid),
1256
1370
  steerInbox,
@@ -1501,6 +1615,7 @@ export class Agent {
1501
1615
  }
1502
1616
  this.context.thoughtSignatures = [];
1503
1617
  this.runMarks.splice(0, this.runMarks.length, ...(data.runs ?? []).filter((r) => r.i < this.context.messages.length));
1618
+ this.restoreUsage(data.usage);
1504
1619
  if (data.systemPrompt) {
1505
1620
  this.context.systemPrompt = data.systemPrompt;
1506
1621
  } else {
@@ -1641,8 +1756,11 @@ export class Agent {
1641
1756
  return s;
1642
1757
  }
1643
1758
 
1644
- // Mid-session: busy agent serializes via steer (same turn) or queue (next turn).
1759
+ // Mid-session: busy agent serializes via steer (same turn) or queue (next turn), or stops the active run and starts this prompt (interrupt).
1645
1760
  if (this.currentRun) {
1761
+ if (options?.enqueue === "interrupt") {
1762
+ return new Promise<AgentResponse>((resolve, reject) => this.queueUrgent({ prompt, options, isStream: false, resolve, reject }));
1763
+ }
1646
1764
  const enforced = this.midSessionConfig.mode;
1647
1765
  const requested = options?.enqueue;
1648
1766
  if (requested && (enforced === "steer" || enforced === "queue") && requested !== enforced) {
@@ -1756,13 +1874,14 @@ export class Agent {
1756
1874
  // Mid-session: busy agent serializes via steer (same turn) or queue (next turn).
1757
1875
  if (this.currentRun) {
1758
1876
  const enforcedStream = this.midSessionConfig.mode;
1759
- const requestedStream = options?.enqueue;
1877
+ const urgent = options?.enqueue === "interrupt"; // stops the active run and goes first, in every mode and whatever the queue holds
1878
+ const requestedStream = options?.enqueue === "interrupt" ? undefined : options?.enqueue;
1760
1879
  if (requestedStream && (enforcedStream === "steer" || enforcedStream === "queue") && requestedStream !== enforcedStream) {
1761
1880
  throw new Error(
1762
1881
  `[Agent Accelerator] enqueue "${requestedStream}" is disabled by midSession.mode "${enforcedStream}". Use ${enforcedStream}() or set mode to "auto"/"${requestedStream}".`
1763
1882
  );
1764
1883
  }
1765
- const mode = this.resolveEnqueueMode(requestedStream);
1884
+ const mode = urgent ? "queue" : this.resolveEnqueueMode(requestedStream);
1766
1885
  if (mode === "steer") {
1767
1886
  this.assertPromptValid(prompt);
1768
1887
  this.assertSteerRoom(this.currentRun.steerInbox);
@@ -1795,14 +1914,14 @@ export class Agent {
1795
1914
  }
1796
1915
  return stub;
1797
1916
  }
1798
- if (this.midSessionConfig.mode === "steer") {
1917
+ if (!urgent && this.midSessionConfig.mode === "steer") {
1799
1918
  throw new Error(
1800
1919
  '[Agent Accelerator] enqueue "queue" is disabled by midSession.mode "steer". Use steer() or set mode to "auto"/"queue".'
1801
1920
  );
1802
1921
  }
1803
1922
  this.assertPromptValid(prompt);
1804
1923
  const max = this.midSessionConfig.maxQueued ?? 20;
1805
- if (this.pendingQueue.length >= max) {
1924
+ if (!urgent && this.pendingQueue.length >= max) {
1806
1925
  throw new Error(
1807
1926
  `[Agent Accelerator] Queue is full (${this.pendingQueue.length}/${max}). Wait for the active run to finish or increase midSession.maxQueued.`
1808
1927
  );
@@ -1810,13 +1929,15 @@ export class Agent {
1810
1929
  const deferred = new AssistantMessageEventStream();
1811
1930
  this.wireStreamCallbacks(deferred, options);
1812
1931
  // Notify the active stream (if any) that a follow-up was queued.
1813
- try {
1814
- this.currentRun.outerStream?.push({
1815
- type: "queued",
1816
- queuedPrompt: this.previewPrompt(prompt),
1817
- queueLength: this.pendingQueue.length + 1,
1818
- } as any);
1819
- } catch {}
1932
+ if (!urgent) {
1933
+ try {
1934
+ this.currentRun.outerStream?.push({
1935
+ type: "queued",
1936
+ queuedPrompt: this.previewPrompt(prompt),
1937
+ queueLength: this.pendingQueue.length + 1,
1938
+ } as any);
1939
+ } catch {}
1940
+ }
1820
1941
  const queuedEntry: {
1821
1942
  prompt: string | ContentPart[];
1822
1943
  options?: AgentRunOptions;
@@ -1852,8 +1973,12 @@ export class Agent {
1852
1973
  }
1853
1974
  } catch {}
1854
1975
  });
1855
- this.pendingQueue.push(queuedEntry as any);
1856
- this.persistNow();
1976
+ if (urgent) {
1977
+ this.queueUrgent(queuedEntry as Agent["pendingQueue"][number]);
1978
+ } else {
1979
+ this.pendingQueue.push(queuedEntry as any);
1980
+ this.persistNow();
1981
+ }
1857
1982
  // Attach deferred completion to queued promise without exposing it.
1858
1983
  // Real events are piped in pumpQueue() when the turn starts.
1859
1984
  void done.then(
@@ -0,0 +1,80 @@
1
+ import type { AgentConfig, EditableAgentConfig } from "../types/agent.ts";
2
+ import type { ToolDefinition } from "../types/tool.ts";
3
+ import { ConfigError } from "../types/errors.ts";
4
+ import { telemetryBus } from "../session/store.ts";
5
+ import { normalizeToolMap } from "./worker.ts";
6
+
7
+ // The building blocks for self-improving agents (RSI): an agent may edit ONLY `model`, `instructions` and `tools`;
8
+ // everything else (limits, budgets, cache, thinking, keys, ...) stays with the developer who wrote `base`.
9
+
10
+ const EDITABLE_FIELDS = ["model", "instructions", "tools"] as const;
11
+
12
+ // The model as a plain `provider/id` string (a string stays as written, a ModelSpec becomes `provider/id`); in: Agent.modelStringOrSpec or a base `model`, out: toConfig, the allowedModels check, applyEditable.
13
+ export function modelKey(model: unknown): string | undefined {
14
+ if (typeof model === "string") return model.trim() || undefined;
15
+ const m = model as { model?: unknown; id?: unknown; provider?: unknown } | null | undefined;
16
+ if (m && typeof m.model === "string") return m.model.trim() || undefined;
17
+ if (m && typeof m.id === "string" && typeof m.provider === "string") return `${m.provider}/${m.id}`;
18
+ return undefined;
19
+ }
20
+
21
+ // Throws when `allowedModels` is set and the model is not in it (entries are compared as written, e.g. "google/gemini-3.5-flash"); in: AgentConfig.allowedModels + the model, out: the Agent constructor and applyEditable.
22
+ export function assertModelAllowed(allowed: unknown, model: string | undefined): void {
23
+ if (allowed === undefined) return;
24
+ if (!Array.isArray(allowed) || allowed.length === 0 || allowed.some((m) => typeof m !== "string" || !m.trim())) {
25
+ throw new ConfigError(`[Agent Accelerator] Invalid allowedModels ${JSON.stringify(allowed)}: expected a non-empty list of "provider/model" strings, or omit it to allow any model.`);
26
+ }
27
+ if (!model || !allowed.includes(model)) {
28
+ throw new ConfigError(`[Agent Accelerator] Model ${JSON.stringify(model ?? null)} is not in allowedModels. Allowed: ${allowed.map((m) => JSON.stringify(m)).join(", ")}.`);
29
+ }
30
+ }
31
+
32
+ // The editable part of an agent as plain JSON; in: the Agent's model, instructions and the developer's tool list (not the built-in delegation/scheduler tools), out: Agent.toConfig.
33
+ export function toEditableConfig(model: unknown, instructions: string, tools: AgentConfig["tools"]): Required<EditableAgentConfig> {
34
+ return { model: modelKey(model) ?? "", instructions, tools: Object.keys(normalizeToolMap(tools)) };
35
+ }
36
+
37
+ // Merges what an agent edited into the developer's config: only model/instructions/tools can change; tools are names picked from base.tools; the model is checked against allowedModels when the Agent is built; in: Agent.fromConfig args, out: the AgentConfig for `new Agent`.
38
+ export function applyEditable(editable: EditableAgentConfig, base: AgentConfig): AgentConfig {
39
+ if (!editable || typeof editable !== "object" || Array.isArray(editable)) {
40
+ throw new ConfigError(`[Agent Accelerator] Invalid editable config ${JSON.stringify(editable)}: expected an object like { model, instructions, tools } (see Agent.toConfig()).`);
41
+ }
42
+ const e = editable as Record<string, unknown>;
43
+ const ignored = Object.keys(e).filter((k) => !(EDITABLE_FIELDS as readonly string[]).includes(k) && e[k] !== undefined);
44
+ if (ignored.length > 0) {
45
+ telemetryBus.emitWarning(
46
+ "config.not_editable",
47
+ `[Agent Accelerator] Ignored ${ignored.map((k) => `"${k}"`).join(", ")} in the editable config: an agent can change only ${EDITABLE_FIELDS.join(", ")}. The rest comes from the developer's config.`,
48
+ { fields: ignored }
49
+ );
50
+ }
51
+ const out: AgentConfig = { ...base };
52
+ if (e.model !== undefined) {
53
+ const next = typeof e.model === "string" ? e.model.trim() : "";
54
+ if (!next) throw new ConfigError(`[Agent Accelerator] Invalid editable model ${JSON.stringify(e.model)}: expected a "provider/model" string.`);
55
+ // A model helper's key and base URL belong to its own provider: they are never sent to another one.
56
+ const prev = base.model as { model?: unknown; apiKey?: string; baseUrl?: string } | string | undefined;
57
+ const sameProvider = next.includes("/") && next.split("/")[0] === modelKey(prev)?.split("/")[0];
58
+ out.model = prev && typeof prev === "object" && typeof prev.model === "string"
59
+ ? { ...prev, model: next, ...(sameProvider ? {} : { apiKey: undefined, baseUrl: undefined }) }
60
+ : next;
61
+ }
62
+ if (e.instructions !== undefined) {
63
+ if (typeof e.instructions !== "string") throw new ConfigError(`[Agent Accelerator] Invalid editable instructions: expected a string, got ${typeof e.instructions}.`);
64
+ out.instructions = e.instructions;
65
+ }
66
+ if (e.tools !== undefined) {
67
+ if (!Array.isArray(e.tools) || e.tools.some((n) => typeof n !== "string")) {
68
+ throw new ConfigError(`[Agent Accelerator] Invalid editable tools ${JSON.stringify(e.tools)}: expected a list of tool names from the developer's tool pool.`);
69
+ }
70
+ const pool = normalizeToolMap(base.tools);
71
+ const unknown = (e.tools as string[]).filter((n) => !Object.hasOwn(pool, n));
72
+ if (unknown.length > 0) {
73
+ throw new ConfigError(`[Agent Accelerator] Unknown tool ${unknown.map((n) => JSON.stringify(n)).join(", ")} in the editable config. Available: ${Object.keys(pool).map((n) => JSON.stringify(n)).join(", ") || "none"}.`);
74
+ }
75
+ const picked: Record<string, ToolDefinition> = {};
76
+ for (const n of e.tools as string[]) picked[n] = pool[n]!;
77
+ out.tools = picked;
78
+ }
79
+ return out; // allowedModels is enforced by the Agent constructor
80
+ }
@@ -11,7 +11,7 @@ function fail(message: string): never {
11
11
  }
12
12
 
13
13
  /** Deep clone that preserves Uint8Array/ArrayBuffer media (structuredClone may be absent). */
14
- function deepCloneValue<T>(value: T): T {
14
+ export function deepCloneValue<T>(value: T): T {
15
15
  if (value instanceof Uint8Array) return new Uint8Array(value) as T;
16
16
  if (value instanceof ArrayBuffer) return value.slice(0) as T;
17
17
  if (Array.isArray(value)) return value.map((v) => deepCloneValue(v)) as T;