agent-accelerator 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -121,11 +121,13 @@ An `Agent` holds:
121
121
  | `tools` | `Record<string, ToolDefinition> \| ToolDefinition[]` | Deterministic functions the model can call. See [Tools](#tools). |
122
122
  | `subagents` | `SubAgent[] \| Agent[]` | Pre-defined workers. Each worker becomes a callable tool. Useful for fixed roles such as researcher or critic. |
123
123
  | `subagentModel` | `string \| ModelSpec \| ModelProviderInstance` | Developer-only model for dynamically spawned workers. Never choosable by the Main Agent. Deprecated alias of `dynamicSubagents.model` (which wins). Falls back to `SUB_AGENT_MODEL`. |
124
- | `dynamicSubagents` | `DynamicSubagentsConfig` | Enables and constrains LLM-spawned workers: `{ enabled, model?, maxSpawn?, maxDepth?, workerBudget?, thinkingLevel?, tools?, timeout?, allowScheduledActions? }` (`timeout` is for scheduled actions only). See [Pre-defined vs Dynamic Delegation](#pre-defined-vs-dynamic-delegation) and [Scheduled actions](#scheduled-actions). |
124
+ | `dynamicSubagents` | `DynamicSubagentsConfig` | Enables and constrains LLM-spawned workers: `{ enabled, model?, maxSpawn?, maxDepth?, workerBudget?, thinkingLevel?, tools?, workerTimeoutMs?, allowScheduledActions? }` (`workerTimeoutMs` is for scheduled actions only). See [Pre-defined vs Dynamic Delegation](#pre-defined-vs-dynamic-delegation) and [Scheduled actions](#scheduled-actions). |
125
125
  | `thinking` | `ThinkingOption` | Canonical reasoning option: `none \| dynamic \| minimal \| low \| medium \| high \| xhigh \| max`, or `{ level, budgetTokens }`. Legacy `thinkingLevel` still works (canonical wins). Validated against the model catalog before a request. |
126
126
  | `cache` | `CacheOption` | `"implicit" \| "5m" \| "30m" \| "1h" \| "12h"`. Prompt-cache lifetime; unsupported values warn and fall back (see [Cache](#cache)). |
127
127
  | `serviceTier` | `"flex" \| "priority"` | Cost / priority routing where supported. Omit for standard routing. |
128
128
  | `maxTurns` | `number` | Maximum model → tool → model loops per `run`. Infinite by default (`0` or omitted = no limit). Hitting a finite limit with pending tool calls warns and sets `finishReason: "max_turns"`. |
129
+ | `limits` | `AgentRunLimits` | `{ maxTurns?, deadlineMs?, maxToolCallsPerRun?, maxTotalTokens?, requestTimeoutMs?, firstByteTimeoutMs?, streamIdleTimeoutMs? }`. The first four are unlimited by default. The three network timeouts default to 300 s / 120 s / 300 s so a silent connection fails instead of hanging; set one to `0` or `Infinity` to turn it off. A timeout raises a retryable `TimeoutError`. See [Safety and operations](docs/guides/safety-and-ops.md#network-timeouts). |
130
+ | `budget` | `Budget` | `{ maxCostUsd?, maxTotalTokens?, maxDurationMs?, maxTotalSubagents? }`, per run, workers included. Unlimited by default; exceeding it throws `BudgetExceededError` with partial usage. |
129
131
  | `sessionId` | `string` | Stable identifier used for cache affinity. Auto-generated when omitted. |
130
132
  | `headers` | `Record<string,string>` | Additional headers merged into every request. |
131
133
  | `apiKey` | `string` | Overrides environment-based API-key lookup for this agent. |
@@ -144,6 +146,11 @@ When `dynamicSubagents.enabled` is set without a resolvable worker model, constr
144
146
  await agent.run(prompt, opts?) // non-streaming, or streaming when opts.stream is true
145
147
  agent.ask(prompt, optsOrBool?) // deprecated (use run): string deltas when streaming, otherwise same as run
146
148
  agent.stream(prompt, opts?) // AssistantMessageEventStream
149
+ agent.steer(prompt) / agent.queue(prompt) // while busy: join the active run / start after it
150
+ agent.interrupt() // stop the active run now (provider request aborted, stream cancelled, tools get ctx.signal)
151
+ await agent.interrupt(message) // stop it AND run `message` next, ahead of queued follow-ups (stream: opts.enqueue = "interrupt")
152
+ agent.toConfig() // { model, instructions, tools }: the part an agent may edit, plain JSON (no keys)
153
+ Agent.fromConfig(editable, base) // new Agent: your full `base` config + an agent's edits of model/instructions/tools only
147
154
  agent.reset() // clears messages + signatures, keeps config
148
155
  agent.exportSession(telemetry?) // storable snapshot for saveSessionFile
149
156
  agent.importSession(saved) // restores messages + config from loadSessionFile
@@ -170,7 +177,8 @@ Use `ContentPart[]` when sending images, audio, or video alongside text.
170
177
  | `additionalContext` | Added only to the current user turn. Keeps the system prompt stable for better caching. |
171
178
  | `sessionId` | Per-run session override. |
172
179
  | `headers` | Per-run header merge. |
173
- | `signal` | `AbortSignal` used to cancel generation, tools, and subagents. |
180
+ | `signal` | `AbortSignal` used to cancel generation, tools, and subagents. The provider request is aborted and a stream's body is cancelled; see [what interrupting stops at the provider](docs/guides/safety-and-ops.md#what-interrupting-stops-at-the-provider). |
181
+ | `enqueue` | When the agent is busy: `"steer"` (join the active run), `"queue"` (run after it) or `"interrupt"` (stop it and run this prompt next, in every `midSession` mode). |
174
182
 
175
183
  ### Streaming
176
184
 
@@ -269,7 +277,7 @@ const agent = new Agent({
269
277
  instructions: "You are the Main Agent",
270
278
  dynamicSubagents: {
271
279
  enabled: true,
272
- maxSpawn: 4, // open workers at once
280
+ maxSpawn: 4, // optional cap on open workers at once (default: no limit)
273
281
  thinkingLevel: "low",
274
282
  tools: { get_weather, recent_news }, // pool: the model grants a subset per worker
275
283
  },
@@ -280,10 +288,10 @@ One `subagents` tool is injected. It takes `{ command, id?, name?, role?, instru
280
288
 
281
289
  - `spawn` starts a worker (`name`, `task`; optional `instructions`, `role`, `tools`, `history`) and waits for its first reply. `message` sends `task` to an open worker `id`, which remembers the whole conversation. `close` frees the slot. `list` shows the open workers and the numbered finished turns that `history: N` can copy into a new worker.
282
290
  - Results are plain text with one header line: `[worker researcher-1a2b3c4d] replied:` + the worker's text, or `[worker researcher-1a2b3c4d] failed: <reason>`. Several `spawn` calls in one model turn run in parallel. A bad request comes back as an `Error: ...` line the model can fix.
283
- - `maxSpawn` is the limit on **open** workers (the model must close one to open another). `budget.maxTotalSubagents` still counts spawns per run.
291
+ - `maxSpawn` caps **open** workers (the model must close one to open another); by default there is no cap. `budget.maxTotalSubagents` still counts spawns per run. Every budget and limit is off until you set it, except three network timeouts (see the `limits` row).
284
292
  - `tools` is the developer-owned pool. Workers get zero tools unless the Main Agent grants a subset by name; unknown names are rejected with the pool listed.
285
- - The Main Agent cannot choose worker models, reasoning levels or timeouts. The `subagents` tool has no timeouts (`workerTimeoutMs`/`timeout` apply only to scheduled actions).
286
- - `maxDepth` (default 3, whole number >= 1) is how many levels of workers may exist below the agent: `1` keeps workers flat, `3` lets a worker start workers that start workers. A worker with levels left gets its own `subagents` tool, and its children can only be granted the tools **it** was granted. The levels are saved inside each other (`<session>/workers/<id>/workers/<id>/…`).
293
+ - The Main Agent cannot choose worker models, reasoning levels or timeouts. The `subagents` tool has no timeouts (`workerTimeoutMs` and an action's `timeoutMs` apply only to scheduled actions).
294
+ - `maxDepth` caps how many levels of workers may exist below the agent. By default there is no cap (a worker can start workers that start workers, as deep as the model goes); set a whole number >= 1 to limit it: `1` keeps workers flat, `3` allows three levels. A worker with levels left gets its own `subagents` tool, and its children can only be granted the tools **it** was granted. The levels are saved inside each other (`<session>/workers/<id>/workers/<id>/…`).
287
295
  - `workerBudget: { maxCostUsd?, maxTotalTokens? }` is each worker's own spending cap, applied as that worker's per-run `budget` (a spawn, a message or a scheduled run is one run; the usage of the worker's own workers counts toward it) and passed down to its workers. Going over fails that call with a budget error (`[worker <id>] failed: Token budget exceeded…`), leaves the worker open and does not affect the parent. Default: unlimited; models with unknown pricing count $0 toward `maxCostUsd`. Values must be numbers above 0.
288
296
  - Workers are real agents with their own session: see [Persistent workers](#persistent-workers-agentworkers) below (the same functions in code, saved on disk with `persist`, resumed after a restart). Each reply's usage is metered into the parent's usage and budget.
289
297
  - Each run of a worker gets a system-generated 32-char TrackingID, displayed as `NAME-{TrackingID}`. Thinking/text deltas update the worker trace live (`agent.track(id)`, `subscribeToSubAgent`) and surface on the parent stream as `subagent_delta` events.
@@ -341,7 +349,7 @@ const agent = new Agent({
341
349
  });
342
350
  ```
343
351
 
344
- The Main Agent then gets two tools, fixed by config so the prompt cache stays stable: `scheduled_actions` (one tool, `command` = `create | list | get | update | kill | run_now | skip`) and `get_datetime` (the machine's local date and time, also exported as `getDatetimeTool`). The tool description is exactly [`src/scheduler/scheduled-actions.md`](src/scheduler/scheduled-actions.md) (the worker pool names are filled in), so you can read what the model is told.
352
+ The Main Agent then gets two tools, fixed by config so the prompt cache stays stable: `scheduled_actions` (one tool, `command` = `create | list | get | update | delete | run_now | skip`) and `get_datetime` (the machine's local date and time, also exported as `getDatetimeTool`). The tool description is exactly [`src/scheduler/scheduled-actions.md`](src/scheduler/scheduled-actions.md) (the worker pool names are filled in), so you can read what the model is told.
345
353
 
346
354
  The same code is available to your program:
347
355
 
@@ -360,6 +368,7 @@ createScheduledAction(agent, {
360
368
  startCookingInAdvance: "00:00:30", // optional, a "DD:HH:MM" duration (default 10 minutes)
361
369
  repeatEvery: "01:00:00", // optional; omit to run once
362
370
  retries: 1, // optional; used only while time remains before delivery
371
+ onMissed: "run", // optional: "skip" | "run" if your program was down at delivery time (omit to decide later)
363
372
  },
364
373
  });
365
374
 
@@ -375,17 +384,19 @@ telemetryBus.onEvent((e) => {
375
384
  | `updateScheduledAction(id, patch, { home? })` | Change instructions, task, tools, timeout, role or any schedule field. Schedule changes are refused while it is cooking. |
376
385
  | `runScheduledAction(id, { home? })` | Run now, outside the schedule (also resolves a missed run). Needs the creating `Agent` alive. |
377
386
  | `skipScheduledAction(id, { home? })` | Ignore a missed run and wait for the next scheduled time. |
378
- | `killScheduledAction(id, { force?, purge?, home? })` | Delete for good. While it is cooking nothing changes unless `force: true`; `purge: true` also deletes past run files. |
387
+ | `deleteScheduledAction(id, { force?, purge?, home? })` | Delete for good. While it is cooking nothing changes unless `force: true`; `purge: true` also deletes past run files. |
379
388
 
380
389
  **How it behaves**
381
390
 
382
391
  * **One local clock.** A single timer per home folder (guarded by a one-process lock, `<home>/.lock`) ticks once a second against the machine's own time and only asks what is overdue, so drift or sleep cannot skip a run. There is no timezone or daylight-saving logic. The timer is `unref`'d and runs only while something is pending, so actions run only while your process is alive.
383
392
  * **The creating `Agent` runs it.** The task stores the Agent's `name`; each run goes to the live Agent with that name (latest wins), using its worker model and tool pool. The scheduler holds only a weak reference, so keep your `Agent` referenced; if none is alive, the run counts as missed.
384
393
  * **Each run is fresh.** A new stateless dynamic sub-agent built from the saved fields, on your fixed worker model and tool pool, never touching the Main Agent's session. It inherits the parent's limits, retry policy, `fileAccess` and `captureRaw`.
385
- * **Files are the truth.** Everything lives in `<home>/scheduled_actions/`: `tasks.json` (every action), `deliveries.json` (saved results waiting for their delivery time) and one folder per action with one `.md` file per run (a small header, then the result). The file is re-read and validated before each run, so editing `instructions` or `task` there takes effect on the next run. API keys, the model and tool code are never stored. Don't put secrets in `instructions`.
386
- * **Ready, then delivered.** A finished run is saved immediately (`scheduled_action.ready`); at `timeToDeliver` the SDK emits `scheduled_action.delivered` on `telemetryBus` with `{ id, name, status, file, readyAt, deliverAt, late }`. There is no notification system: what happens next is your code. Other events: `.created`, `.updated`, `.started`, `.failed`, `.missed`, `.skipped`, `.killed`, plus the warnings `.invalid` and `.store_corrupt`.
394
+ * **Files are the truth.** Everything lives in `<home>/scheduled_actions/` (the format is a stable contract with a changelog: [docs/scheduled-action-files.md](docs/scheduled-action-files.md)): `tasks.json` (every action), `deliveries.json` (saved results waiting for their delivery time) and one folder per action with one `.md` file per run (a small header, then the result). The file is re-read and validated before each run, so editing `instructions` or `task` there takes effect on the next run. API keys, the model and tool code are never stored. Don't put secrets in `instructions`.
395
+ * **What a run costs.** Each run file's header records the run's exact spending, the same numbers `usageReport()` keeps for workers: `turns`, `tokens` (input + output), `inputTokens`, `freshInputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `outputTokens`, `thinkingTokens`, `cacheHitRate` (0 to 1), `costUsd`, `costSource` (`provider`, `catalog`, `mixed` or `unknown`) and `unpricedTurns` (only when above 0, then `costUsd` is a lower bound). Unknown pricing is `costUsd: unknown`, never 0. The numbers cover every attempt of the run (`retries` included) and are recorded for failed runs too. The report itself does not list scheduled runs.
396
+ * **Time limit of a run.** An action may carry `timeoutMs` (top level of the action, not inside `scheduled_action`): a run that takes longer is stopped and recorded as failed. By default there is no limit. `dynamicSubagents.workerTimeoutMs` is an optional ceiling: an action cannot ask for more, and it is the limit for actions that set none (`0` and `Infinity` mean no ceiling). The former `dynamicSubagents.timeout`, `modelMaySetTimeout` and the `-1` mode were removed; passing them throws a `ConfigError` that says what to use.
397
+ * **Ready, then delivered.** A finished run is saved immediately (`scheduled_action.ready`); at `timeToDeliver` the SDK emits `scheduled_action.delivered` on `telemetryBus` with `{ id, name, status, file, readyAt, deliverAt, late }`. There is no notification system: what happens next is your code. Other events: `.created`, `.updated`, `.started`, `.failed`, `.missed`, `.skipped`, `.deleted`, plus the warnings `.invalid` and `.store_corrupt`.
387
398
  * **Overlap and failure.** A run still cooking when the next one is due is skipped, never stacked. A failed run still writes a file saying why and is delivered like any result (`status: "failed"`).
388
- * **Missed runs.** If the process was down when a run was due, the SDK emits `scheduled_action.missed` once and runs nothing by itself: pick `run_now` or `skip`. A result saved before the process exited is simply delivered (marked `late`) on the first tick after start.
399
+ * **Missed runs.** If the process was down when a run was due, the SDK emits `scheduled_action.missed` once. By default it runs nothing by itself and the action waits as `missed`: pick `run_now` or `skip`. Set `scheduled_action.onMissed` per action to decide in advance: `"skip"` moves on to the next time (a one-shot is `done`; a `skipped` event follows), `"run"` runs it as soon as the program is back (delivered when it finishes, marked `late`). The same applies to a run a crash cut off. An action whose creating Agent is gone stays `missed` whatever `onMissed` says (nothing can run it). A result saved before the process exited is simply delivered (marked `late`) on the first tick after start. `late` means the result was delivered more than two ticks after its time, finished after its delivery second, or replaced a missed run. A `run_now` result is delivered when it finishes: it is `late` only when it replaces a missed run (a `run_now` on a `missed` action, or `onMissed: "run"`), never for an ordinary early run.
389
400
 
390
401
  ---
391
402
  ## Tools
@@ -702,9 +713,12 @@ Thinking levels map to `reasoning.effort` verbatim, the same as OpenAI (`dynamic
702
713
  It also:
703
714
 
704
715
  * passes `service_tier` (`flex` | `priority`);
705
- * enables the `file-parser` plugin when file parts are present (remote file URLs are additionally surfaced in text);
716
+ * sends audio with its real format (`wav`, `mp3`, `aiff`, `aac`, `ogg`, `flac`, `m4a`) and QuickTime video as `video/mov`;
717
+ * sends file parts as `file.file_data` (a public URL or a base64 data URL) and never adds a plugin: OpenRouter reads the file natively or with its own default parser;
706
718
  * maps `tool_choice` (`auto` omitted; `none`, `required`, and function pins supported);
707
- * reports `cached_tokens` / `cache_write_tokens` / reasoning tokens, preferring provider-reported `cost` when present.
719
+ * treats an error chunk inside a stream (HTTP 200) like an HTTP error: its status comes from the chunk's `code`, or from its `error_type` (`rate_limit_exceeded` 429, `provider_overloaded` 503, `provider_unavailable` 502, `timeout` 504, `server` 500), so `runRetries` retries the retryable ones;
720
+ * reports `cached_tokens` / `cache_write_tokens` / reasoning tokens, preferring provider-reported `cost` when present (a reported `0`, a free model or a cache hit, stays a known `$0`);
721
+ * accepts `anthropic/...` and `~anthropic/...` model names for the `5m` / `1h` cache setting, and sends at most 4 `stop` sequences (the Chat Completions maximum; extra ones are dropped with one `provider_warning`).
708
722
 
709
723
  `serviceTier` values other than `flex` / `priority` are omitted (standard routing).
710
724
 
@@ -1048,7 +1062,7 @@ report.total; // agent + all workers
1048
1062
  - **Own turns only.** Every agent records the model calls it made itself, priced **per turn** in integer micro-USD, and saves them in its session (`usage`), so the numbers survive a restart (load the session and the totals carry on; workers are rebuilt from their folders). A parent's `res.usage` already contains its workers' usage, which is why the report never adds responses together: `report.total.tokens.total` equals what the responses show.
1049
1063
  - **Cache-aware.** `input` includes the cache reads and writes (`freshInput = input - cacheRead - cacheWrite`); `output` includes `thinking`. Fresh input, cache reads and cache writes are each priced at their own catalog price, and `serviceTier` is applied. `cacheHitRate` is `cacheRead / input` (0 to 1, `null` when nothing was sent).
1050
1064
  - **Honest costs.** `cost.source` is `provider` (the provider reported the cost, which is exact), `catalog` (estimated from the model catalog), `mixed`, or `unknown`. Unknown pricing is `totalUsd: null`, never 0; `unpricedTurns` above 0 means the total is a lower bound. For turns whose cost the provider reported only the total is exact, so `parts` can add up to less than `totalUsd`.
1051
- - **Not in the report:** the one-shot workers of scheduled actions (their cost is in each run file), and workers started inside a fixed `subagents` worker's per-call copy (their cost is in the response usage). `reset()` does not clear the totals: they are spending, not conversation.
1065
+ - **Not in the report:** the one-shot workers of scheduled actions (their exact cost is in each run file's header), and workers started inside a fixed `subagents` worker's per-call copy (their cost is in the response usage). `reset()` does not clear the totals: they are spending, not conversation.
1052
1066
 
1053
1067
  ### ProviderRawData
1054
1068
 
@@ -1289,8 +1303,11 @@ documents. The supported lists are the `GOOGLE_*_MIME` constants at the top of
1289
1303
  `resolution` (`MediaResolution`: `"low" | "medium" | "high" | "ultra_high"`)
1290
1304
  sets how much detail Google spends on one image or video, and so how many
1291
1305
  input tokens it costs (image: 280 / 560 / 1120 / 2240; video per frame:
1292
- `low` and `medium` 70, `high` 280). Omit it for Google's default. Other
1293
- providers ignore it with one `provider_warning`.
1306
+ `low` and `medium` 70, `high` 280). Omit it for Google's default. On
1307
+ OpenRouter an image's `resolution` is sent as `image_url.detail` (`low` → `low`,
1308
+ `medium` → `auto`, `high` → `high`, `ultra_high` → `original`, which OpenRouter
1309
+ downgrades to `high` where unsupported); a video's `resolution` is ignored. Other
1310
+ providers (and OpenRouter videos) ignore it with one `provider_warning`.
1294
1311
 
1295
1312
  Before any network call, the executor checks `image` / `audio` / `video` /
1296
1313
  `file`-as-PDF parts against the catalog's `modalities.input` for that
@@ -1348,7 +1365,7 @@ bun run examples/11-session-identity.ts
1348
1365
  # sub-agent TrackingIDs, live worker boxes, lineage, track(), durable sessions
1349
1366
 
1350
1367
  bun run examples/12-mid-session.ts
1351
- # steer, queue and stream while a turn is running
1368
+ # steer, queue and stream while a turn is running (interrupt() stops it; interrupt(message) stops it and runs the message)
1352
1369
 
1353
1370
  bun run examples/13-structured-output.ts
1354
1371
  # JSON Schema structured outputs on every provider
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-accelerator",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "High-performance SDK for AI agents with a unified API across AI providers. Optimized for high cache hit rates, observability, and seamless agent orchestration. Built to power production-grade agentic systems and harnesses for teams at any scale.",
5
5
  "license": "MIT",
6
6
  "author": "Sashvat Bharat",
@@ -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";
@@ -25,6 +25,7 @@ import { resolveHome } from "../scheduler/store.ts";
25
25
  import { persistTelemetry } from "../telemetry-file.ts";
26
26
  import { lockFolder } from "../utils/durable.ts";
27
27
  import { createScheduledActionsTool, getDatetimeTool } from "../scheduler/tool.ts";
28
+ import { applyEditable, assertModelAllowed, modelKey, toEditableConfig } from "./config-blocks.ts";
28
29
  import { assertNoToolCollisions } from "../tools/executor.ts";
29
30
  import { runAgentLoop, streamAgentLoop, unfinishedToolCalls, endsWithStop, accumulateUsage, type SteerEntry } from "./loop.ts";
30
31
  import { isRetryableRun, readRetryAfterMs, sleep } from "../utils/retry.ts";
@@ -95,6 +96,8 @@ function resolveAgentLimits(config: { limits?: Record<string, unknown> }): Agent
95
96
  const raw = (config as { limits?: Record<string, unknown> }).limits ?? {};
96
97
  const num = (v: unknown): number | undefined =>
97
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);
98
101
  return {
99
102
  ...(num(raw.maxTurns) !== undefined ? { maxTurns: Math.max(1, Math.floor(num(raw.maxTurns)!)) } : {}),
100
103
  ...(num(raw.deadlineMs) !== undefined ? { deadlineMs: num(raw.deadlineMs)! } : {}),
@@ -102,9 +105,9 @@ function resolveAgentLimits(config: { limits?: Record<string, unknown> }): Agent
102
105
  ? { maxToolCallsPerRun: Math.max(1, Math.floor(num(raw.maxToolCallsPerRun)!)) }
103
106
  : {}),
104
107
  ...(num(raw.maxTotalTokens) !== undefined ? { maxTotalTokens: Math.max(1, Math.floor(num(raw.maxTotalTokens)!)) } : {}),
105
- requestTimeoutMs: num(raw.requestTimeoutMs) ?? 300_000,
106
- firstByteTimeoutMs: num(raw.firstByteTimeoutMs) ?? 120_000,
107
- 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,
108
111
  };
109
112
  }
110
113
 
@@ -165,13 +168,15 @@ export class Agent {
165
168
  readonly dynamicSubagents?: {
166
169
  enabled: boolean;
167
170
  model?: string | any;
171
+ /** Open workers at once; `Infinity` (the default) = no limit. */
168
172
  maxSpawn: number;
169
173
  thinkingLevel?: ThinkingLevel;
170
174
  tools: Record<string, ToolDefinition>;
171
- timeout: number;
175
+ /** Longest a scheduled run may take, in ms; `Infinity` (the default) = no limit. */
176
+ workerTimeoutMs: number;
172
177
  /** Scheduled actions (`scheduled_actions` + `get_datetime` tools) are on. */
173
178
  allowScheduledActions: boolean;
174
- /** Levels of workers allowed below this agent. */
179
+ /** Levels of workers allowed below this agent; `Infinity` (the default) = no limit. */
175
180
  maxDepth: number;
176
181
  /** Per-run spending cap of each worker (unset = unlimited). */
177
182
  workerBudget?: { maxCostUsd?: number; maxTotalTokens?: number };
@@ -257,6 +262,8 @@ export class Agent {
257
262
  deferredStream?: AssistantMessageEventStream;
258
263
  resolve: (res: AgentResponse) => void;
259
264
  reject: (err: unknown) => void;
265
+ /** Put here by `interrupt(message)`: runs before every other waiting prompt. */
266
+ urgent?: boolean;
260
267
  }> = [];
261
268
  /** Named session contexts for `sessionId` runs (Q-19a, LRU 100 + TTL). */
262
269
  private sessions = new Map<string, { context: AgentContext; lastAccess: number }>();
@@ -311,6 +318,7 @@ export class Agent {
311
318
  // ModelProviderInstance {model,apiKey,...} and ModelSpec {id,provider}.
312
319
  const { modelStringOrSpec, rawModel } = resolveMainModel(config.model);
313
320
  this.modelStringOrSpec = modelStringOrSpec as string | any;
321
+ assertModelAllowed(config.allowedModels, modelKey(modelStringOrSpec));
314
322
  const dynRaw = config.dynamicSubagents;
315
323
  // Unified worker-model chain: dynamicSubagents.model > subagentModel > SUB_AGENT_MODEL.
316
324
  const dynModel = resolveDynamicModelInput(dynRaw, config.subagentModel);
@@ -319,8 +327,9 @@ export class Agent {
319
327
  if (dynRaw) {
320
328
  const dynTools = normalizeToolMap((dynRaw as { tools?: Record<string, ToolDefinition> | ToolDefinition[] }).tools);
321
329
  const rawDepth = (dynRaw as { maxDepth?: unknown }).maxDepth;
322
- if (rawDepth !== undefined && !(Number.isInteger(rawDepth) && (rawDepth as number) >= 1)) {
323
- throw new ConfigError(`[Agent Accelerator] Invalid dynamicSubagents.maxDepth ${JSON.stringify(rawDepth)}: expected a whole number >= 1 (default ${DEFAULT_MAX_DEPTH}); set dynamicSubagents.enabled: false to turn workers off.`);
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.`);
324
333
  }
325
334
  const rawBudget = (dynRaw as { workerBudget?: { maxCostUsd?: unknown; maxTotalTokens?: unknown } }).workerBudget;
326
335
  for (const key of ["maxCostUsd", "maxTotalTokens"] as const) {
@@ -329,17 +338,30 @@ export class Agent {
329
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.`);
330
339
  }
331
340
  }
332
- const rawMax = (dynRaw as any)?.maxSpawn;
333
- const maxSpawn = Number.isFinite(rawMax) ? Math.max(1, Math.floor(rawMax as number)) : 4;
334
- // `workerTimeoutMs ?? timeout`, or -1 when `modelMaySetTimeout` (see normalizeWorkerTimeout).
335
- const timeout = normalizeWorkerTimeout(dynRaw as { timeout?: number; workerTimeoutMs?: number; modelMaySetTimeout?: boolean });
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));
336
358
  this.dynamicSubagents = {
337
359
  enabled: dynEnabled,
338
360
  model: dynModel as string | any | undefined,
339
361
  maxSpawn,
340
362
  thinkingLevel: (dynRaw as any)?.thinkingLevel,
341
363
  tools: dynTools,
342
- timeout,
364
+ workerTimeoutMs,
343
365
  allowScheduledActions: dynRaw.allowScheduledActions === true,
344
366
  maxDepth: (rawDepth as number | undefined) ?? DEFAULT_MAX_DEPTH,
345
367
  ...(rawBudget ? { workerBudget: { ...(rawBudget as { maxCostUsd?: number; maxTotalTokens?: number }) } } : {}),
@@ -805,22 +827,46 @@ export class Agent {
805
827
  }
806
828
 
807
829
  /**
808
- * Interrupts the active run immediately (STOP. Do this instead).
809
- * Cancels the active stream and aborts provider/tool/sub-agent work via
810
- * the run's internal `AbortController`. No-op when idle. The interrupted
811
- * 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" })`.
812
836
  */
813
- 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 {
814
851
  const active = this.currentRun;
815
852
  if (!active) return;
816
853
  try {
817
- active.abortController.abort(reason instanceof Error ? reason : new Error("Agent interrupted"));
854
+ active.abortController.abort(reason ?? new Error("Agent interrupted"));
818
855
  } catch {}
819
856
  try {
820
857
  active.outerStream?.cancel(reason);
821
858
  } catch {}
822
859
  }
823
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
+
824
870
  /**
825
871
  * Steers the active turn (While you're doing that, change direction).
826
872
  * The current provider/tool work finishes first, then `prompt` is injected
@@ -1009,7 +1055,9 @@ export class Agent {
1009
1055
  reject: entry.settle?.reject ?? (() => {}),
1010
1056
  });
1011
1057
  }
1012
- 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);
1013
1061
  } catch {}
1014
1062
  }
1015
1063
  this.persistNow();
@@ -1105,6 +1153,24 @@ export class Agent {
1105
1153
  return copy;
1106
1154
  }
1107
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
+
1108
1174
  /** Budgets are per run: counters restart whenever a new run begins. */
1109
1175
  private resetBudgetState(): void {
1110
1176
  if (this.budget) this.budgetState = { spawned: 0, totalTokens: 0, totalCostUsd: 0, startedAtMs: nowMs() };
@@ -1690,8 +1756,11 @@ export class Agent {
1690
1756
  return s;
1691
1757
  }
1692
1758
 
1693
- // 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).
1694
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
+ }
1695
1764
  const enforced = this.midSessionConfig.mode;
1696
1765
  const requested = options?.enqueue;
1697
1766
  if (requested && (enforced === "steer" || enforced === "queue") && requested !== enforced) {
@@ -1805,13 +1874,14 @@ export class Agent {
1805
1874
  // Mid-session: busy agent serializes via steer (same turn) or queue (next turn).
1806
1875
  if (this.currentRun) {
1807
1876
  const enforcedStream = this.midSessionConfig.mode;
1808
- 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;
1809
1879
  if (requestedStream && (enforcedStream === "steer" || enforcedStream === "queue") && requestedStream !== enforcedStream) {
1810
1880
  throw new Error(
1811
1881
  `[Agent Accelerator] enqueue "${requestedStream}" is disabled by midSession.mode "${enforcedStream}". Use ${enforcedStream}() or set mode to "auto"/"${requestedStream}".`
1812
1882
  );
1813
1883
  }
1814
- const mode = this.resolveEnqueueMode(requestedStream);
1884
+ const mode = urgent ? "queue" : this.resolveEnqueueMode(requestedStream);
1815
1885
  if (mode === "steer") {
1816
1886
  this.assertPromptValid(prompt);
1817
1887
  this.assertSteerRoom(this.currentRun.steerInbox);
@@ -1844,14 +1914,14 @@ export class Agent {
1844
1914
  }
1845
1915
  return stub;
1846
1916
  }
1847
- if (this.midSessionConfig.mode === "steer") {
1917
+ if (!urgent && this.midSessionConfig.mode === "steer") {
1848
1918
  throw new Error(
1849
1919
  '[Agent Accelerator] enqueue "queue" is disabled by midSession.mode "steer". Use steer() or set mode to "auto"/"queue".'
1850
1920
  );
1851
1921
  }
1852
1922
  this.assertPromptValid(prompt);
1853
1923
  const max = this.midSessionConfig.maxQueued ?? 20;
1854
- if (this.pendingQueue.length >= max) {
1924
+ if (!urgent && this.pendingQueue.length >= max) {
1855
1925
  throw new Error(
1856
1926
  `[Agent Accelerator] Queue is full (${this.pendingQueue.length}/${max}). Wait for the active run to finish or increase midSession.maxQueued.`
1857
1927
  );
@@ -1859,13 +1929,15 @@ export class Agent {
1859
1929
  const deferred = new AssistantMessageEventStream();
1860
1930
  this.wireStreamCallbacks(deferred, options);
1861
1931
  // Notify the active stream (if any) that a follow-up was queued.
1862
- try {
1863
- this.currentRun.outerStream?.push({
1864
- type: "queued",
1865
- queuedPrompt: this.previewPrompt(prompt),
1866
- queueLength: this.pendingQueue.length + 1,
1867
- } as any);
1868
- } 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
+ }
1869
1941
  const queuedEntry: {
1870
1942
  prompt: string | ContentPart[];
1871
1943
  options?: AgentRunOptions;
@@ -1901,8 +1973,12 @@ export class Agent {
1901
1973
  }
1902
1974
  } catch {}
1903
1975
  });
1904
- this.pendingQueue.push(queuedEntry as any);
1905
- 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
+ }
1906
1982
  // Attach deferred completion to queued promise without exposing it.
1907
1983
  // Real events are piped in pumpQueue() when the turn starts.
1908
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
+ }
package/src/agent/loop.ts CHANGED
@@ -342,9 +342,11 @@ function resolveLoopLimits(config: AgentLoopConfig, fallbackMaxTurns: number): L
342
342
  const deadlineMs = num(raw.deadlineMs) ?? Infinity;
343
343
  const maxToolCallsPerRun = num(raw.maxToolCallsPerRun) ?? Infinity;
344
344
  const maxTotalTokens = num(raw.maxTotalTokens) ?? Infinity;
345
- const requestTimeoutMs = num(raw.requestTimeoutMs) ?? 300_000;
346
- const firstByteTimeoutMs = num(raw.firstByteTimeoutMs) ?? 120_000;
347
- const streamIdleTimeoutMs = num(raw.streamIdleTimeoutMs) ?? 300_000;
345
+ // A network timeout: `0` or `Infinity` turns it off, any other number is milliseconds, anything else is unset (the default applies).
346
+ const timeout = (v: unknown): number | undefined => (typeof v === "number" && !Number.isNaN(v) ? v : undefined);
347
+ const requestTimeoutMs = timeout(raw.requestTimeoutMs) ?? 300_000;
348
+ const firstByteTimeoutMs = timeout(raw.firstByteTimeoutMs) ?? 120_000;
349
+ const streamIdleTimeoutMs = timeout(raw.streamIdleTimeoutMs) ?? 300_000;
348
350
  return { maxTurns, deadlineMs, maxToolCallsPerRun, maxTotalTokens, requestTimeoutMs, firstByteTimeoutMs, streamIdleTimeoutMs };
349
351
  }
350
352
 
@@ -416,7 +418,7 @@ async function generateWithTimeout<T>(task: () => Promise<T>, timeoutMs: number)
416
418
  /** Prices one usage snapshot with the one cost model (`priceUsage`): provider-reported total first, else catalog pricing scaled by `serviceTier`; undefined when unknown. In: loop turns and `accumulateUsage`, out: `usage.cost`. */
417
419
  export function computeCostFromPricing(usage: TokenUsage, spec?: ModelSpec, serviceTier?: string): TokenUsage["cost"] {
418
420
  try {
419
- if (usage.cost && usage.cost.totalCost !== undefined && usage.cost.totalCost > 0) {
421
+ if (usage.cost && typeof usage.cost.totalCost === "number" && usage.cost.totalCost >= 0) {
420
422
  return usage.cost;
421
423
  }
422
424
  if (!spec) return usage.cost;
@@ -29,11 +29,11 @@ function asResult(reply: WorkerReply): ReturnType<typeof makeSubagentResult> {
29
29
  /** Builds the `subagents` tool for `agent`; its description is the manual with the worker pool and the open-worker limit filled in. */
30
30
  export function createSubagentsTool(agent: Agent): ToolDefinition {
31
31
  const poolNames = Object.keys(agent.dynamicSubagents?.tools ?? {});
32
- const max = agent.dynamicSubagents?.maxSpawn ?? 4;
32
+ const max = agent.dynamicSubagents?.maxSpawn ?? Infinity;
33
33
  return tool({
34
34
  name: "subagents",
35
35
  description: loadManual()
36
- .replace(/\{\{MAX\}\}/g, String(max))
36
+ .replace("{{LIMIT}}", Number.isFinite(max) ? `You can have at most ${max} open workers. Close the ones you are done with.` : "There is no limit on open workers. Close the ones you are done with: each one keeps its whole conversation.")
37
37
  .replace("{{TOOLS}}", poolNames.length > 0 ? poolNames.join(", ") : "none (omit tools)"),
38
38
  input: z.object({
39
39
  command: z.enum(COMMANDS),
@@ -3,7 +3,7 @@ Starts helpers ("workers") that work on a task for you and can be asked again la
3
3
  COMMANDS (one tool, the `command` field)
4
4
  - spawn: start a worker. Needs `name` and `task`. Optional: `instructions` (the worker's own system prompt), `role`, `tools`, `history`. It waits for the worker's first reply and gives you the worker's `id`.
5
5
  - message: send `task` to the open worker `id`. The worker remembers everything said to it before. It waits for the reply.
6
- - close: end the worker `id` when you no longer need it. This frees one of your {{MAX}} open slots. A closed worker cannot be messaged again.
6
+ - close: end the worker `id` when you no longer need it. A closed worker cannot be messaged again.
7
7
  - list: your open workers, plus the numbered turns of this conversation that `history` can copy.
8
8
 
9
9
  FIELDS
@@ -25,7 +25,7 @@ Ask it a follow-up:
25
25
  RULES
26
26
  - Several workers at once: make several spawn calls in the same turn. They run in parallel.
27
27
  - A worker handles one call at a time. Wait for its reply before messaging it again.
28
- - You can have at most {{MAX}} open workers. Close the ones you are done with.
28
+ - {{LIMIT}}
29
29
  - A worker starts blank: put everything it needs in `instructions` and `task`, or copy earlier turns with `history`.
30
30
  - Worker answers are written by another model. Check them before you rely on them.
31
31
  - Never put secrets in `instructions` or `task`.