agent-accelerator 0.6.0 → 0.6.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 +62 -18
- package/package.json +1 -1
- package/src/agent/agent.ts +192 -695
- package/src/agent/architecture.md +19 -13
- package/src/agent/context.ts +1 -0
- package/src/agent/defaults.ts +88 -0
- package/src/agent/delegation.ts +8 -3
- package/src/agent/loop.ts +242 -226
- package/src/agent/named-sessions.ts +93 -0
- package/src/agent/options.ts +166 -0
- package/src/agent/run-control.ts +248 -0
- package/src/agent/subagents-tool.ts +14 -21
- package/src/agent/usage-limit.ts +92 -0
- package/src/agent/worker.ts +17 -3
- package/src/agent/workers.ts +13 -1
- package/src/architecture.md +1 -0
- package/src/index.ts +11 -11
- package/src/prompts/additional-context.md +2 -0
- package/src/prompts/architecture.md +19 -0
- package/src/prompts/prompts.ts +81 -0
- package/src/prompts/reasoning-directive.md +5 -0
- package/src/prompts/structured-output.md +2 -0
- package/src/prompts/tool-bridge.md +1 -0
- package/src/prompts/tool-interrupted.md +1 -0
- package/src/prompts/tool-not-found.md +1 -0
- package/src/prompts/tool-repeat-blocked.md +1 -0
- package/src/prompts/tool-schema-error.md +1 -0
- package/src/prompts/tool-truncated-arguments.md +1 -0
- package/src/prompts/worker-failed.md +1 -0
- package/src/prompts/worker-replied.md +2 -0
- package/src/providers/architecture.md +8 -8
- package/src/providers/google.ts +83 -13
- package/src/providers/openai-compat.ts +17 -7
- package/src/providers/openai.ts +10 -4
- package/src/providers/openrouter.ts +185 -18
- package/src/providers.ts +59 -9
- package/src/scheduler/architecture.md +7 -7
- package/src/scheduler/scheduler.ts +40 -0
- package/src/scheduler/tool.ts +5 -9
- package/src/session/journal.ts +1 -0
- package/src/session/store.ts +4 -0
- package/src/streaming/event-stream.ts +1 -1
- package/src/tools/architecture.md +7 -6
- package/src/tools/executor.ts +60 -15
- package/src/tools/tool-content.ts +32 -0
- package/src/types/agent.ts +40 -8
- package/src/types/core.ts +4 -0
- package/src/types/message.ts +7 -0
- package/src/types/model.ts +4 -1
- package/src/types/scheduler.ts +4 -4
- package/src/types/tool.ts +2 -0
- package/src/utils/errors.ts +3 -3
- package/src/utils/headers.ts +1 -1
- /package/src/{scheduler/scheduled-actions.md → prompts/scheduled-actions-tool.md} +0 -0
- /package/src/{agent/subagents.md → prompts/subagents-tool.md} +0 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The named in-memory sessions of one Agent (`run(prompt, { sessionId })`, `agent.session(id)`): one `AgentContext` per
|
|
3
|
+
* id, at most 100 kept (least recently used goes first) and dropped after an hour idle. The Agent's own session is
|
|
4
|
+
* never kept here: it is its default context.
|
|
5
|
+
*/
|
|
6
|
+
import { AgentContext } from "./context.ts";
|
|
7
|
+
import { clearSessionRouting } from "../providers.ts";
|
|
8
|
+
import { clearInteractionChains } from "../providers/google.ts";
|
|
9
|
+
|
|
10
|
+
/** Max named sessions kept per Agent (LRU). */
|
|
11
|
+
const SESSION_LRU_MAX = 100;
|
|
12
|
+
/** Idle TTL for named sessions. */
|
|
13
|
+
const SESSION_TTL_MS = 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
/** The named sessions of one Agent. */
|
|
16
|
+
export class NamedSessions {
|
|
17
|
+
private readonly entries = new Map<string, { context: AgentContext; lastAccess: number }>();
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly defaultId: () => string,
|
|
21
|
+
private readonly defaultContext: () => AgentContext,
|
|
22
|
+
private readonly create: () => AgentContext
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
// Forgets a session's provider chain and routing state; in: every removal below, out: nothing.
|
|
26
|
+
private forget(id: string): void {
|
|
27
|
+
try {
|
|
28
|
+
clearInteractionChains(id);
|
|
29
|
+
} catch {}
|
|
30
|
+
try {
|
|
31
|
+
clearSessionRouting(id);
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The context for `sessionId` (the default session gives the Agent's own context); expired ones are dropped and the oldest is evicted past the limit. */
|
|
36
|
+
contextFor(sessionId?: string): AgentContext {
|
|
37
|
+
const sid = sessionId || this.defaultId();
|
|
38
|
+
if (sid === this.defaultId()) return this.defaultContext();
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
try {
|
|
41
|
+
for (const [key, entry] of [...this.entries]) {
|
|
42
|
+
if (now - entry.lastAccess > SESSION_TTL_MS) {
|
|
43
|
+
this.entries.delete(key);
|
|
44
|
+
this.forget(key);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
const existing = this.entries.get(sid);
|
|
49
|
+
if (existing) {
|
|
50
|
+
existing.lastAccess = now;
|
|
51
|
+
return existing.context;
|
|
52
|
+
}
|
|
53
|
+
const fresh = this.create();
|
|
54
|
+
this.entries.set(sid, { context: fresh, lastAccess: now });
|
|
55
|
+
try {
|
|
56
|
+
while (this.entries.size > SESSION_LRU_MAX) {
|
|
57
|
+
let oldest: string | undefined;
|
|
58
|
+
let oldestTs = Infinity;
|
|
59
|
+
for (const [key, entry] of this.entries) {
|
|
60
|
+
if (entry.lastAccess < oldestTs) {
|
|
61
|
+
oldestTs = entry.lastAccess;
|
|
62
|
+
oldest = key;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!oldest) break;
|
|
66
|
+
this.entries.delete(oldest);
|
|
67
|
+
this.forget(oldest);
|
|
68
|
+
}
|
|
69
|
+
} catch {}
|
|
70
|
+
return fresh;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Drops one session and its provider state; no-op for an unknown id. */
|
|
74
|
+
dispose(id: string): void {
|
|
75
|
+
try {
|
|
76
|
+
this.entries.delete(id);
|
|
77
|
+
} catch {}
|
|
78
|
+
this.forget(id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Drops every named session. */
|
|
82
|
+
clear(): void {
|
|
83
|
+
try {
|
|
84
|
+
for (const id of [...this.entries.keys()]) this.forget(id);
|
|
85
|
+
this.entries.clear();
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The ids of the named sessions held now. */
|
|
90
|
+
ids(): string[] {
|
|
91
|
+
return [...this.entries.keys()];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Option handling for an Agent: the normalized run limits, the thinking level, the tool choice and the one function
|
|
3
|
+
* that turns agent config plus per-run options into what every provider call receives.
|
|
4
|
+
*/
|
|
5
|
+
import type { AgentConfig, AgentRunOptions, RetryPolicyConfig, ToolChoiceOption } from "../types/agent.ts";
|
|
6
|
+
import type { ProviderRequestOptions, StructuredOutputSpec } from "../types/model.ts";
|
|
7
|
+
import type { CacheOption, ServiceTier, ThinkingConfig, ThinkingLevel } from "../types/core.ts";
|
|
8
|
+
import type { Prompts } from "../prompts/prompts.ts";
|
|
9
|
+
import { normalizeThinkingV2 } from "../tools/tool.ts";
|
|
10
|
+
import { validateModelThinking } from "../models/catalog.ts";
|
|
11
|
+
import { telemetryBus } from "../telemetry.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolves a per-run thinking override without mutating agent config.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveEffectiveThinking(base?: ThinkingConfig, overrideLevel?: string): ThinkingConfig | undefined {
|
|
17
|
+
if (!overrideLevel) return base;
|
|
18
|
+
if (overrideLevel === "none") return { enabled: false, level: "none", budgetTokens: 0 };
|
|
19
|
+
if (overrideLevel === "dynamic") return { enabled: true, level: "dynamic", budgetTokens: -1 };
|
|
20
|
+
return { enabled: true, level: overrideLevel as ThinkingConfig["level"] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Normalized per-run limits. Sub-agents inherit these unless overridden. */
|
|
24
|
+
export interface AgentRunLimits {
|
|
25
|
+
maxTurns?: number;
|
|
26
|
+
deadlineMs?: number;
|
|
27
|
+
maxToolCallsPerRun?: number;
|
|
28
|
+
maxTotalTokens?: number;
|
|
29
|
+
requestTimeoutMs: number;
|
|
30
|
+
firstByteTimeoutMs: number;
|
|
31
|
+
streamIdleTimeoutMs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolves `(config as any).limits` with defaults (timeouts tolerate reasoning). */
|
|
35
|
+
export function resolveAgentLimits(config: { limits?: Record<string, unknown> }): AgentRunLimits {
|
|
36
|
+
const raw = (config as { limits?: Record<string, unknown> }).limits ?? {};
|
|
37
|
+
const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
38
|
+
// A network timeout: `0` or `Infinity` turns it off, any other number is milliseconds, anything else is unset (the default applies).
|
|
39
|
+
const timeout = (v: unknown): number | undefined => (typeof v === "number" && !Number.isNaN(v) ? v : undefined);
|
|
40
|
+
return {
|
|
41
|
+
...(num(raw.maxTurns) !== undefined ? { maxTurns: Math.max(1, Math.floor(num(raw.maxTurns)!)) } : {}),
|
|
42
|
+
...(num(raw.deadlineMs) !== undefined ? { deadlineMs: num(raw.deadlineMs)! } : {}),
|
|
43
|
+
...(num(raw.maxToolCallsPerRun) !== undefined
|
|
44
|
+
? { maxToolCallsPerRun: Math.max(1, Math.floor(num(raw.maxToolCallsPerRun)!)) }
|
|
45
|
+
: {}),
|
|
46
|
+
...(num(raw.maxTotalTokens) !== undefined
|
|
47
|
+
? { maxTotalTokens: Math.max(1, Math.floor(num(raw.maxTotalTokens)!)) }
|
|
48
|
+
: {}),
|
|
49
|
+
requestTimeoutMs: timeout(raw.requestTimeoutMs) ?? 300_000,
|
|
50
|
+
firstByteTimeoutMs: timeout(raw.firstByteTimeoutMs) ?? 120_000,
|
|
51
|
+
streamIdleTimeoutMs: timeout(raw.streamIdleTimeoutMs) ?? 300_000,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The Agent settings `buildProviderOptions` reads. */
|
|
56
|
+
export interface ProviderOptionSource {
|
|
57
|
+
apiKey?: string;
|
|
58
|
+
baseUrl?: string;
|
|
59
|
+
customHeaders?: Record<string, string>;
|
|
60
|
+
thinkingConfig?: ThinkingConfig;
|
|
61
|
+
cache?: CacheOption;
|
|
62
|
+
serviceTier?: ServiceTier;
|
|
63
|
+
sessionId: string;
|
|
64
|
+
outputSpec?: StructuredOutputSpec;
|
|
65
|
+
prompts: Prompts;
|
|
66
|
+
googleTier?: "free" | "paid";
|
|
67
|
+
callDefaults: {
|
|
68
|
+
toolChoice?: ToolChoiceOption;
|
|
69
|
+
maxOutputTokens?: number;
|
|
70
|
+
retry?: RetryPolicyConfig;
|
|
71
|
+
env?: Record<string, string>;
|
|
72
|
+
fetch?: typeof fetch;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Single place that turns agent config + per-run options into the options
|
|
78
|
+
* every provider call receives. Per-run values override agent defaults
|
|
79
|
+
* (objects such as `retry` and `env` merge key by key).
|
|
80
|
+
*/
|
|
81
|
+
export function buildProviderOptions(
|
|
82
|
+
agent: ProviderOptionSource,
|
|
83
|
+
runOptions: AgentRunOptions | undefined
|
|
84
|
+
): ProviderRequestOptions {
|
|
85
|
+
const d = agent.callDefaults;
|
|
86
|
+
const sessionId = runOptions?.sessionId || agent.sessionId;
|
|
87
|
+
const toolChoice = normalizeToolChoiceOption(runOptions?.toolChoice ?? d.toolChoice);
|
|
88
|
+
const retry = { ...d.retry, ...runOptions?.retry };
|
|
89
|
+
const env = { ...d.env, ...runOptions?.env };
|
|
90
|
+
const maxOutputTokens = runOptions?.maxOutputTokens ?? d.maxOutputTokens;
|
|
91
|
+
const fetchImpl = runOptions?.fetch ?? d.fetch;
|
|
92
|
+
return {
|
|
93
|
+
apiKey: agent.apiKey,
|
|
94
|
+
baseUrl: agent.baseUrl,
|
|
95
|
+
headers: { ...(agent.customHeaders ?? {}), ...(runOptions?.headers ?? {}) },
|
|
96
|
+
thinking: resolveEffectiveThinking(agent.thinkingConfig, runOptions?.thinkingLevel),
|
|
97
|
+
...(agent.cache ? { cache: agent.cache } : {}),
|
|
98
|
+
serviceTier: agent.serviceTier,
|
|
99
|
+
sessionId,
|
|
100
|
+
...(agent.outputSpec ? { output: agent.outputSpec } : {}),
|
|
101
|
+
prompts: { structuredOutput: agent.prompts.structuredOutput, toolBridge: agent.prompts.toolBridge },
|
|
102
|
+
...(agent.googleTier ? { googleTier: agent.googleTier } : {}),
|
|
103
|
+
...(toolChoice !== undefined ? { toolChoice } : {}),
|
|
104
|
+
...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
|
|
105
|
+
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
106
|
+
...(fetchImpl ? { fetch: fetchImpl } : {}),
|
|
107
|
+
...(retry.maxRetries !== undefined ? { maxRetries: retry.maxRetries } : {}),
|
|
108
|
+
...(retry.maxDelayMs !== undefined ? { maxRetryDelayMs: retry.maxDelayMs } : {}),
|
|
109
|
+
...(retry.baseDelayMs !== undefined ? { retryBaseDelayMs: retry.baseDelayMs } : {}),
|
|
110
|
+
...(retry.budgetMs !== undefined ? { retryBudgetMs: retry.budgetMs } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Normalizes the public `thinkingLevel` flag into provider-neutral thinking settings. */
|
|
115
|
+
export function normalizeThinking(config: AgentConfig, fallbackLevel?: string): ThinkingConfig | undefined {
|
|
116
|
+
// DX4: only thinkingLevel, values: none, dynamic, minimal, low, medium, high, xhigh, max
|
|
117
|
+
// `thinking` (canonical) wins over legacy `thinkingLevel`; `{ level }` objects are unwrapped.
|
|
118
|
+
const rawLevel = normalizeThinkingV2(config.thinking, config.thinkingLevel) ?? fallbackLevel;
|
|
119
|
+
if (typeof config.thinking === "object" && config.thinking?.budgetTokens !== undefined) {
|
|
120
|
+
// No provider maps an explicit token budget yet: say so instead of ignoring it silently.
|
|
121
|
+
telemetryBus.emitWarning(
|
|
122
|
+
"config.thinking_budget_ignored",
|
|
123
|
+
"[Agent Accelerator] thinking.budgetTokens is not forwarded by any provider adapter yet; the level alone is used.",
|
|
124
|
+
{ budgetTokens: config.thinking.budgetTokens }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const level = rawLevel as ThinkingLevel | undefined;
|
|
128
|
+
|
|
129
|
+
if (!level) return undefined;
|
|
130
|
+
|
|
131
|
+
// Validate model thinking from catalog if model is configured
|
|
132
|
+
if (config.model) {
|
|
133
|
+
try {
|
|
134
|
+
const rawModel: any = (config.model as any)?.model ? (config.model as any).model : config.model;
|
|
135
|
+
const modelStr = typeof rawModel === "string" ? rawModel : rawModel?.id || "";
|
|
136
|
+
const provStr =
|
|
137
|
+
typeof rawModel === "object" && rawModel?.provider
|
|
138
|
+
? rawModel.provider
|
|
139
|
+
: modelStr.includes("/")
|
|
140
|
+
? modelStr.split("/")[0]
|
|
141
|
+
: "";
|
|
142
|
+
const actualModelId = modelStr.includes("/") ? modelStr.split("/").slice(1).join("/") : modelStr;
|
|
143
|
+
if (actualModelId) {
|
|
144
|
+
validateModelThinking(provStr || "openrouter", actualModelId, level);
|
|
145
|
+
}
|
|
146
|
+
} catch (e) {
|
|
147
|
+
throw e;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (level === "none") {
|
|
152
|
+
return { enabled: false, level: "none", budgetTokens: 0 };
|
|
153
|
+
}
|
|
154
|
+
if (level === "dynamic") {
|
|
155
|
+
return { enabled: true, level: "dynamic", budgetTokens: -1 };
|
|
156
|
+
}
|
|
157
|
+
return { enabled: true, level };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Maps the public `toolChoice` option onto the provider wire-neutral form. */
|
|
161
|
+
export function normalizeToolChoiceOption(choice: ToolChoiceOption | undefined): ProviderRequestOptions["toolChoice"] {
|
|
162
|
+
if (!choice) return undefined;
|
|
163
|
+
if (typeof choice === "string") return choice;
|
|
164
|
+
if (choice.tool) return { type: "function", function: { name: choice.tool } };
|
|
165
|
+
return choice.mode;
|
|
166
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mid-session control of one Agent: the run that is active now, the follow-ups waiting behind it, and what happens when
|
|
3
|
+
* a new prompt arrives while busy (`steer`, `queue`, `interrupt`). `Agent` keeps the public methods and calls into this
|
|
4
|
+
* class; the class only needs the few things in {@link RunHost}.
|
|
5
|
+
*/
|
|
6
|
+
import type { AgentRunOptions, MidSessionConfig } from "../types/agent.ts";
|
|
7
|
+
import type { ContentPart } from "../types/message.ts";
|
|
8
|
+
import type { AgentResponse, StreamEvent } from "../types/response.ts";
|
|
9
|
+
import type { AssistantMessageEventStream } from "../streaming/event-stream.ts";
|
|
10
|
+
import { ValidationError } from "../types/errors.ts";
|
|
11
|
+
import type { SteerEntry } from "./loop.ts";
|
|
12
|
+
|
|
13
|
+
/** The run that is active right now. */
|
|
14
|
+
export interface ActiveRun {
|
|
15
|
+
steerInbox: SteerEntry[];
|
|
16
|
+
outerStream?: AssistantMessageEventStream;
|
|
17
|
+
runPromise?: Promise<AgentResponse>;
|
|
18
|
+
abortController: AbortController;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A prompt waiting for the active run to finish. */
|
|
22
|
+
export interface QueuedPrompt {
|
|
23
|
+
prompt: string | ContentPart[];
|
|
24
|
+
options?: AgentRunOptions;
|
|
25
|
+
isStream: boolean;
|
|
26
|
+
deferredStream?: AssistantMessageEventStream;
|
|
27
|
+
resolve: (res: AgentResponse) => void;
|
|
28
|
+
reject: (err: unknown) => void;
|
|
29
|
+
/** Put here by `interrupt(message)`: runs before every other waiting prompt. */
|
|
30
|
+
urgent?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What the run control needs from its Agent. */
|
|
34
|
+
export interface RunHost {
|
|
35
|
+
persistNow(): void;
|
|
36
|
+
run(prompt: string | ContentPart[], options?: AgentRunOptions): Promise<AgentResponse>;
|
|
37
|
+
startNonStreamingRun(prompt: string | ContentPart[], options?: AgentRunOptions): Promise<AgentResponse>;
|
|
38
|
+
startStreamingRun(prompt: string | ContentPart[], options?: AgentRunOptions): AssistantMessageEventStream;
|
|
39
|
+
readonly midSessionConfig: Required<Pick<MidSessionConfig, "mode">> & MidSessionConfig;
|
|
40
|
+
readonly maxSteers: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The active run and the waiting queue of one Agent. */
|
|
44
|
+
export class RunControl {
|
|
45
|
+
/** The run in flight, or null when idle. */
|
|
46
|
+
current: ActiveRun | null = null;
|
|
47
|
+
/** Prompts waiting behind it (urgent ones first). */
|
|
48
|
+
readonly queue: QueuedPrompt[] = [];
|
|
49
|
+
|
|
50
|
+
constructor(private readonly host: RunHost) {}
|
|
51
|
+
|
|
52
|
+
/** Throws when the steer inbox is at its bound. */
|
|
53
|
+
assertSteerRoom(inbox: SteerEntry[]): void {
|
|
54
|
+
const max = this.host.maxSteers ?? 20;
|
|
55
|
+
if (inbox.length >= max) {
|
|
56
|
+
throw new ValidationError(
|
|
57
|
+
`[Agent Accelerator] Steer inbox is full (${inbox.length}/${max}). Wait for the active run to drain or increase midSession.maxSteers.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Picks steer or queue for a call that arrives while busy: the developer's enforced midSession mode wins over the per-call request; in: run()/stream() `enqueue`, out: their busy branch.
|
|
63
|
+
resolveEnqueueMode(requested?: "steer" | "queue"): "steer" | "queue" {
|
|
64
|
+
const enforced = this.host.midSessionConfig.mode;
|
|
65
|
+
if (enforced === "steer" || enforced === "queue") return enforced;
|
|
66
|
+
return requested === "steer" ? "steer" : "queue";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 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.
|
|
70
|
+
abortActive(reason?: Error): void {
|
|
71
|
+
const active = this.current;
|
|
72
|
+
if (!active) return;
|
|
73
|
+
try {
|
|
74
|
+
active.abortController.abort(reason ?? new Error("Agent interrupted"));
|
|
75
|
+
} catch {}
|
|
76
|
+
try {
|
|
77
|
+
active.outerStream?.cancel(reason);
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 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.
|
|
82
|
+
queueUrgent(entry: QueuedPrompt): void {
|
|
83
|
+
entry.urgent = true;
|
|
84
|
+
const firstOrdinary = this.queue.findIndex((e) => !e.urgent);
|
|
85
|
+
this.queue.splice(firstOrdinary < 0 ? this.queue.length : firstOrdinary, 0, entry);
|
|
86
|
+
this.host.persistNow();
|
|
87
|
+
this.abortActive();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Buffers a steer prompt into the active run and returns a promise for ITS
|
|
92
|
+
* reply: the active run's response when the prompt is injected, or (if the
|
|
93
|
+
* run ends first and the prompt becomes a queued follow-up) the follow-up
|
|
94
|
+
* run's response, so a late steer's answer is never lost.
|
|
95
|
+
*/
|
|
96
|
+
steerInto(active: ActiveRun, prompt: string | ContentPart[], options?: AgentRunOptions): Promise<AgentResponse> {
|
|
97
|
+
this.assertSteerRoom(active.steerInbox);
|
|
98
|
+
const runDone = active.runPromise ?? active.outerStream?.result();
|
|
99
|
+
if (!runDone) return this.host.run(prompt, options) as Promise<AgentResponse>;
|
|
100
|
+
const entry: SteerEntry = { prompt, options };
|
|
101
|
+
const reply = new Promise<AgentResponse>((resolve, reject) => {
|
|
102
|
+
entry.settle = { resolve, reject };
|
|
103
|
+
});
|
|
104
|
+
reply.catch(() => {}); // a fire-and-forget caller must not trigger an unhandled rejection
|
|
105
|
+
active.steerInbox.push(entry);
|
|
106
|
+
this.host.persistNow();
|
|
107
|
+
runDone.then(
|
|
108
|
+
(res) => {
|
|
109
|
+
if (!entry.settle?.deferredToQueue) entry.settle?.resolve(res);
|
|
110
|
+
},
|
|
111
|
+
(err) => {
|
|
112
|
+
if (!entry.settle?.deferredToQueue) entry.settle?.reject(err);
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
return reply;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Starts the next waiting prompt (urgent ones first, aborted ones rejected) as a streaming or non-streaming run once the agent is idle; in: finishCurrentRun and queue changes, out: a new run whose result settles the queued caller.
|
|
119
|
+
pumpQueue(): void {
|
|
120
|
+
if (this.current !== null) return;
|
|
121
|
+
const next = this.queue.shift();
|
|
122
|
+
if (!next) return;
|
|
123
|
+
this.host.persistNow();
|
|
124
|
+
// Reject queued items whose signal aborted before start.
|
|
125
|
+
try {
|
|
126
|
+
if ((next.options as { signal?: AbortSignal } | undefined)?.signal?.aborted) {
|
|
127
|
+
const reason = ((next.options as { signal?: AbortSignal }).signal as unknown as { reason?: unknown })?.reason;
|
|
128
|
+
const abortErr = Object.assign(
|
|
129
|
+
reason instanceof Error ? reason : new Error("Queued run aborted before start"),
|
|
130
|
+
{ name: "AbortError" }
|
|
131
|
+
);
|
|
132
|
+
try {
|
|
133
|
+
next.reject(abortErr);
|
|
134
|
+
} catch {}
|
|
135
|
+
try {
|
|
136
|
+
next.deferredStream?.fail(abortErr instanceof Error ? abortErr : new Error(String(abortErr)));
|
|
137
|
+
} catch {}
|
|
138
|
+
queueMicrotask(() => this.pumpQueue());
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
} catch {}
|
|
142
|
+
if (next.isStream && next.deferredStream) {
|
|
143
|
+
const deferred = next.deferredStream;
|
|
144
|
+
try {
|
|
145
|
+
const real = this.host.startStreamingRun(next.prompt, next.options);
|
|
146
|
+
try {
|
|
147
|
+
(deferred as any).__piped = true;
|
|
148
|
+
} catch {}
|
|
149
|
+
const forward = (e: StreamEvent) => {
|
|
150
|
+
try {
|
|
151
|
+
deferred.push(e);
|
|
152
|
+
} catch {}
|
|
153
|
+
};
|
|
154
|
+
real.on("*", forward as any);
|
|
155
|
+
try {
|
|
156
|
+
deferred.onCancel(() => {
|
|
157
|
+
try {
|
|
158
|
+
real.cancel();
|
|
159
|
+
} catch {}
|
|
160
|
+
});
|
|
161
|
+
} catch {}
|
|
162
|
+
real.result().then(
|
|
163
|
+
(res) => {
|
|
164
|
+
try {
|
|
165
|
+
next.resolve(res);
|
|
166
|
+
} catch {}
|
|
167
|
+
try {
|
|
168
|
+
deferred.end(res);
|
|
169
|
+
} catch {}
|
|
170
|
+
},
|
|
171
|
+
(err) => {
|
|
172
|
+
try {
|
|
173
|
+
next.reject(err);
|
|
174
|
+
} catch {}
|
|
175
|
+
// Pipe failures into the deferred stream so for-await sees them.
|
|
176
|
+
try {
|
|
177
|
+
deferred.fail(err instanceof Error ? err : new Error(String(err)));
|
|
178
|
+
} catch {}
|
|
179
|
+
}
|
|
180
|
+
);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
try {
|
|
183
|
+
next.reject(err);
|
|
184
|
+
} catch {}
|
|
185
|
+
try {
|
|
186
|
+
deferred.fail(err instanceof Error ? err : new Error(String(err)));
|
|
187
|
+
} catch {}
|
|
188
|
+
// Keep draining even if starting failed.
|
|
189
|
+
queueMicrotask(() => this.pumpQueue());
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.host.startNonStreamingRun(next.prompt, next.options).then(next.resolve, next.reject);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Ends the active run slot: leftover steers become follow-ups at the FRONT of the queue (behind urgent ones), then the queue is pumped; in: a settled run, out: pendingQueue + persistNow.
|
|
197
|
+
finishCurrentRun(): void {
|
|
198
|
+
// Leftover steer entries become queued follow-ups instead of dropping.
|
|
199
|
+
let leftover: SteerEntry[] = [];
|
|
200
|
+
try {
|
|
201
|
+
leftover = this.current?.steerInbox?.splice(0) ?? [];
|
|
202
|
+
} catch {
|
|
203
|
+
leftover = [];
|
|
204
|
+
}
|
|
205
|
+
this.current = null;
|
|
206
|
+
if (leftover.length > 0) {
|
|
207
|
+
try {
|
|
208
|
+
const max = this.host.midSessionConfig.maxQueued ?? 20;
|
|
209
|
+
// Late steers were typed BEFORE anything already queued: they go to the front, in the order they arrived.
|
|
210
|
+
const lateSteers: QueuedPrompt[] = [];
|
|
211
|
+
for (const entry of leftover) {
|
|
212
|
+
try {
|
|
213
|
+
if ((entry.options as { signal?: AbortSignal } | undefined)?.signal?.aborted) {
|
|
214
|
+
entry.settle?.reject(
|
|
215
|
+
Object.assign(new Error("Steer aborted before it could run"), { name: "AbortError" })
|
|
216
|
+
);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (this.queue.length + lateSteers.length >= max) {
|
|
223
|
+
entry.settle?.reject(
|
|
224
|
+
new ValidationError("[Agent Accelerator] Steer dropped: the follow-up queue is full.")
|
|
225
|
+
);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// The steer was never injected: it now runs as a follow-up, and the
|
|
229
|
+
// caller's promise resolves with that run's response.
|
|
230
|
+
if (entry.settle) entry.settle.deferredToQueue = true;
|
|
231
|
+
lateSteers.push({
|
|
232
|
+
prompt: entry.prompt,
|
|
233
|
+
options: entry.options,
|
|
234
|
+
isStream: false,
|
|
235
|
+
resolve: entry.settle?.resolve ?? (() => {}),
|
|
236
|
+
reject: entry.settle?.reject ?? (() => {}),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
// ...but behind an interrupt(message), which is the newest and most urgent instruction.
|
|
240
|
+
const firstOrdinary = this.queue.findIndex((e) => !e.urgent);
|
|
241
|
+
this.queue.splice(firstOrdinary < 0 ? this.queue.length : firstOrdinary, 0, ...lateSteers);
|
|
242
|
+
} catch {}
|
|
243
|
+
}
|
|
244
|
+
this.host.persistNow();
|
|
245
|
+
// Defer pump so the completing run's .then handlers settle first.
|
|
246
|
+
queueMicrotask(() => this.pumpQueue());
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The model-facing `subagents` tool: one flat tool with a `command` field (`spawn | message | close | list`), a thin
|
|
3
|
-
* wrapper over `agent.workers`. The tool manual lives in `subagents.md`, not in code. Results are plain text with one
|
|
3
|
+
* wrapper over `agent.workers`. The tool manual lives in `src/prompts/subagents-tool.md`, not in code. Results are plain text with one
|
|
4
4
|
* header line naming the worker; the branded envelope under them carries usage and lineage to the loop.
|
|
5
5
|
*/
|
|
6
|
-
import { readFileSync } from "node:fs";
|
|
7
6
|
import { z } from "zod";
|
|
8
7
|
import type { Agent } from "./agent.ts";
|
|
9
8
|
import { tool } from "../tools/tool.ts";
|
|
@@ -11,20 +10,15 @@ import { makeSubagentResult } from "../tools/subagent-result.ts";
|
|
|
11
10
|
import { ValidationError } from "../types/errors.ts";
|
|
12
11
|
import type { ToolDefinition } from "../types/tool.ts";
|
|
13
12
|
import type { WorkerReply } from "./workers.ts";
|
|
13
|
+
import { fillPrompt, type Prompts } from "../prompts/prompts.ts";
|
|
14
14
|
|
|
15
15
|
const COMMANDS = ["spawn", "message", "close", "list"] as const;
|
|
16
16
|
|
|
17
|
-
let manual: string | undefined;
|
|
18
|
-
/** Read on first use (not at import), so a program that never enables workers never touches the file. */
|
|
19
|
-
function loadManual(): string {
|
|
20
|
-
return (manual ??= readFileSync(new URL("./subagents.md", import.meta.url), "utf8"));
|
|
21
|
-
}
|
|
22
|
-
|
|
23
17
|
// One worker reply as the model sees it: a header line naming the worker, then its text; in: a WorkerReply, out: the branded result.
|
|
24
|
-
function asResult(reply: WorkerReply): ReturnType<typeof makeSubagentResult> {
|
|
18
|
+
function asResult(reply: WorkerReply, prompts: Prompts): ReturnType<typeof makeSubagentResult> {
|
|
25
19
|
const text = reply.error
|
|
26
|
-
?
|
|
27
|
-
:
|
|
20
|
+
? fillPrompt(prompts.workerFailed, { id: reply.id, reason: reply.error })
|
|
21
|
+
: fillPrompt(prompts.workerReplied, { id: reply.id, text: reply.text });
|
|
28
22
|
return makeSubagentResult(text, [reply.metadata]);
|
|
29
23
|
}
|
|
30
24
|
|
|
@@ -34,14 +28,12 @@ export function createSubagentsTool(agent: Agent): ToolDefinition {
|
|
|
34
28
|
const max = agent.dynamicSubagents?.maxSpawn ?? Infinity;
|
|
35
29
|
return tool({
|
|
36
30
|
name: "subagents",
|
|
37
|
-
description:
|
|
38
|
-
.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
44
|
-
.replace("{{TOOLS}}", poolNames.length > 0 ? poolNames.join(", ") : "none (omit tools)"),
|
|
31
|
+
description: fillPrompt(agent.prompts.subagentsTool, {
|
|
32
|
+
LIMIT: Number.isFinite(max)
|
|
33
|
+
? `You can have at most ${max} open workers. Close the ones you are done with.`
|
|
34
|
+
: "There is no limit on open workers. Close the ones you are done with: each one keeps its whole conversation.",
|
|
35
|
+
TOOLS: poolNames.length > 0 ? poolNames.join(", ") : "none (omit tools)",
|
|
36
|
+
}),
|
|
45
37
|
input: z.object({
|
|
46
38
|
command: z.enum(COMMANDS),
|
|
47
39
|
id: z.string().optional().describe("The worker's full id (message, close)."),
|
|
@@ -89,10 +81,11 @@ export function createSubagentsTool(agent: Agent): ToolDefinition {
|
|
|
89
81
|
history: a.history,
|
|
90
82
|
},
|
|
91
83
|
opts
|
|
92
|
-
)
|
|
84
|
+
),
|
|
85
|
+
agent.prompts
|
|
93
86
|
);
|
|
94
87
|
case "message":
|
|
95
|
-
return asResult(await agent.workers.message(need("id"), need("task"), opts));
|
|
88
|
+
return asResult(await agent.workers.message(need("id"), need("task"), opts), agent.prompts);
|
|
96
89
|
case "close":
|
|
97
90
|
return `Worker ${agent.workers.close(need("id")).id} closed.`;
|
|
98
91
|
case "list":
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chat-wide spending cap (`AgentConfig.usageLimit`). One `UsageLimit` belongs to the root agent of a chat and is
|
|
3
|
+
* handed to every agent below it (registry workers at any depth, fixed `subagents` workers), so each model turn of the
|
|
4
|
+
* whole tree adds to one running total and every turn is checked before it starts. The total survives a restart: the
|
|
5
|
+
* first run rebuilds the starting point from the saved sessions of the agent and its workers (`usageReport()`), after
|
|
6
|
+
* which only new turns are added. Scheduled actions are separate and never count.
|
|
7
|
+
*/
|
|
8
|
+
import { BudgetExceededError } from "../types/errors.ts";
|
|
9
|
+
|
|
10
|
+
/** What the cap is made of; either part may be set, the first one reached stops the chat. */
|
|
11
|
+
export interface UsageLimitConfig {
|
|
12
|
+
maxCostUsd?: number;
|
|
13
|
+
maxTotalTokens?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A one-line problem with a `usageLimit` value, or undefined when it is valid; in: the Agent constructor, out: its ConfigError. */
|
|
17
|
+
export function checkUsageLimitConfig(raw: unknown): string | undefined {
|
|
18
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
19
|
+
return "expected an object like { maxCostUsd, maxTotalTokens }";
|
|
20
|
+
const r = raw as Record<string, unknown>;
|
|
21
|
+
const keys = Object.keys(r).filter((k) => r[k] !== undefined);
|
|
22
|
+
if (keys.length === 0) return "set maxCostUsd, maxTotalTokens or both";
|
|
23
|
+
for (const k of keys) {
|
|
24
|
+
if (k !== "maxCostUsd" && k !== "maxTotalTokens") return `unknown key "${k}" (use maxCostUsd or maxTotalTokens)`;
|
|
25
|
+
const v = r[k];
|
|
26
|
+
if (!(typeof v === "number" && Number.isFinite(v) && v > 0)) return `${k} must be a number greater than 0`;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The running total of one chat and its limit. */
|
|
32
|
+
export class UsageLimit {
|
|
33
|
+
private baseTokens = 0;
|
|
34
|
+
private baseMicroUSD = 0;
|
|
35
|
+
private addedTokens = 0;
|
|
36
|
+
private addedMicroUSD = 0;
|
|
37
|
+
private started = false;
|
|
38
|
+
|
|
39
|
+
constructor(readonly limit: UsageLimitConfig) {}
|
|
40
|
+
|
|
41
|
+
/** Takes the starting point (what the chat spent before this process) once; later calls do nothing until `restart()`. */
|
|
42
|
+
begin(startingPoint: () => { tokens: number; microUSD: number }): void {
|
|
43
|
+
if (this.started) return;
|
|
44
|
+
this.started = true;
|
|
45
|
+
const s = startingPoint();
|
|
46
|
+
this.baseTokens = s.tokens;
|
|
47
|
+
this.baseMicroUSD = s.microUSD;
|
|
48
|
+
this.addedTokens = 0;
|
|
49
|
+
this.addedMicroUSD = 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Forgets the total (a session was imported over this agent): the next `begin` takes a new starting point. */
|
|
53
|
+
restart(): void {
|
|
54
|
+
this.started = false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Adds one model turn of any agent in the chat. Unpriced turns add tokens only (unknown pricing is never a made-up $0 of cost). */
|
|
58
|
+
add(tokens: number, microUSD: number): void {
|
|
59
|
+
this.addedTokens += tokens;
|
|
60
|
+
this.addedMicroUSD += microUSD;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What the chat has spent so far. */
|
|
64
|
+
spent(): { tokens: number; costUsd: number } {
|
|
65
|
+
return {
|
|
66
|
+
tokens: this.baseTokens + this.addedTokens,
|
|
67
|
+
costUsd: (this.baseMicroUSD + this.addedMicroUSD) / 1_000_000,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Throws a `BudgetExceededError` ("Usage limit reached") once the chat has spent its whole limit. */
|
|
72
|
+
check(): void {
|
|
73
|
+
const { tokens, costUsd } = this.spent();
|
|
74
|
+
const { maxCostUsd, maxTotalTokens } = this.limit;
|
|
75
|
+
const byCost = maxCostUsd !== undefined && costUsd >= maxCostUsd;
|
|
76
|
+
const byTokens = maxTotalTokens !== undefined && tokens >= maxTotalTokens;
|
|
77
|
+
if (!byCost && !byTokens) return;
|
|
78
|
+
const parts = [
|
|
79
|
+
...(maxCostUsd !== undefined ? [`$${costUsd.toFixed(6)} of $${maxCostUsd}`] : []),
|
|
80
|
+
...(maxTotalTokens !== undefined ? [`${tokens} of ${maxTotalTokens} tokens`] : []),
|
|
81
|
+
];
|
|
82
|
+
throw new BudgetExceededError(
|
|
83
|
+
`[Agent Accelerator] Usage limit reached: ${parts.join(", ")} spent by this chat (the agent and all its workers).`,
|
|
84
|
+
{
|
|
85
|
+
context: {
|
|
86
|
+
limit: "usageLimit",
|
|
87
|
+
partial: { usage: { inputTokens: 0, outputTokens: 0, totalTokens: tokens }, turns: 0 },
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|