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.
- package/README.md +84 -37
- package/examples/prompts/SYSTEM_PROMPT_AGENT.md +14 -20
- package/package.json +1 -1
- package/src/agent/agent.ts +181 -56
- package/src/agent/config-blocks.ts +80 -0
- package/src/agent/context.ts +1 -1
- package/src/agent/delegation.ts +208 -524
- package/src/agent/loop.ts +18 -14
- package/src/agent/subagents-tool.ts +87 -0
- package/src/agent/subagents.md +31 -0
- package/src/agent/turns.ts +60 -0
- package/src/agent/worker.ts +43 -33
- package/src/agent/workers.ts +405 -0
- package/src/index.ts +6 -5
- package/src/providers/openrouter.ts +49 -225
- package/src/providers/shared.ts +2 -2
- package/src/scheduler/scheduled-actions.md +4 -2
- package/src/scheduler/scheduler.ts +87 -50
- package/src/scheduler/store.ts +23 -1
- package/src/scheduler/tool.ts +9 -8
- package/src/session/journal.ts +12 -1
- package/src/session/store.ts +24 -10
- package/src/session/usage.ts +201 -0
- package/src/streaming/event-stream.ts +7 -2
- package/src/tools/executor.ts +17 -2
- package/src/tools/subagent-result.ts +1 -1
- package/src/tools/tool.ts +0 -16
- package/src/types/agent.ts +44 -16
- package/src/types/scheduler.ts +9 -2
- package/src/types/tool.ts +1 -1
- package/src/utils/headers.ts +1 -1
package/src/agent/agent.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
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:
|
|
102
|
-
firstByteTimeoutMs:
|
|
103
|
-
streamIdleTimeoutMs:
|
|
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
|
-
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
|
442
|
-
this.tools[
|
|
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
|
-
*
|
|
763
|
-
*
|
|
764
|
-
* the run
|
|
765
|
-
*
|
|
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?:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
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
|
-
|
|
1856
|
-
|
|
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
|
+
}
|
package/src/agent/context.ts
CHANGED
|
@@ -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;
|