agent-accelerator 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -14
- package/package.json +1 -1
- package/src/agent/agent.ts +1010 -104
- package/src/agent/context.ts +35 -0
- package/src/agent/delegation.ts +758 -40
- package/src/agent/loop.ts +811 -102
- package/src/agent/subagent.ts +59 -0
- package/src/index.ts +186 -59
- package/src/models/catalog-cache.ts +68 -25
- package/src/models/catalog.ts +101 -21
- package/src/providers/google.ts +99 -39
- package/src/providers/openai-compat.ts +140 -80
- package/src/providers/openai.ts +149 -69
- package/src/providers/openrouter.ts +106 -71
- package/src/providers/registry.ts +16 -12
- package/src/providers/shared.ts +132 -0
- package/src/providers.ts +14 -13
- package/src/session/store.ts +1285 -0
- package/src/streaming/event-stream.ts +156 -41
- package/src/tools/executor.ts +252 -16
- package/src/tools/tool.ts +147 -3
- package/src/types/agent.ts +253 -2
- package/src/types/core.ts +2 -1
- package/src/types/errors.ts +133 -0
- package/src/types/model.ts +1 -1
- package/src/types/response.ts +150 -3
- package/src/types/tool.ts +71 -0
- package/src/update-models.ts +8 -2
- package/src/utils/cache.ts +68 -1
- package/src/utils/env.ts +2 -6
- package/src/utils/headers.ts +153 -19
- package/src/utils/retry.ts +27 -13
- package/src/utils/session.ts +320 -0
- package/src/providers/openrouter-responses.ts +0 -949
- package/src/types/provider-payloads.ts +0 -352
package/src/agent/delegation.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { tool } from "../tools/tool.ts";
|
|
3
3
|
import { normalizeToolName } from "../tools/executor.ts";
|
|
4
|
+
import { escapeXml } from "../utils/serialization.ts";
|
|
4
5
|
import type { ToolDefinition } from "../types/tool.ts";
|
|
5
6
|
import type { SubAgentExecutionMetadata } from "../types/response.ts";
|
|
6
7
|
import type { Agent } from "./agent.ts";
|
|
8
|
+
import type { Budget, BudgetState } from "../types/agent.ts";
|
|
9
|
+
import { BudgetExceededError } from "../types/errors.ts";
|
|
7
10
|
import { resolveModel } from "../providers/registry.ts";
|
|
11
|
+
import { hashSessionPart } from "../utils/session.ts";
|
|
12
|
+
import { createTrackingId } from "../utils/session.ts";
|
|
13
|
+
import { nowMs } from "../utils/session.ts";
|
|
14
|
+
import { newRunId } from "../utils/session.ts";
|
|
15
|
+
import { newTurnId } from "../utils/session.ts";
|
|
16
|
+
import type { SubAgentStep } from "../types/response.ts";
|
|
17
|
+
|
|
18
|
+
export { escapeXml };
|
|
8
19
|
|
|
9
20
|
/** Task descriptor accepted by the automatic `spawn_subagents` tool.
|
|
10
21
|
*
|
|
@@ -26,26 +37,17 @@ export interface DynamicSubagentTask {
|
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
function sanitizeXmlTag(raw: string): string {
|
|
29
|
-
let s = raw.toUpperCase().replace(/[^A-Z0-
|
|
30
|
-
if (!/^[A-
|
|
31
|
-
s = s.replace(/[
|
|
40
|
+
let s = raw.toUpperCase().replace(/[^A-Z0-9.-]/g, "-");
|
|
41
|
+
if (!/^[A-Z]/.test(s)) s = `AGENT-${s}`;
|
|
42
|
+
s = s.replace(/[.]/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
|
|
32
43
|
return s.slice(0, 64) || "SUBAGENT";
|
|
33
44
|
}
|
|
34
45
|
function sanitizeToolName(raw: string): string {
|
|
35
46
|
const tag = sanitizeXmlTag(raw);
|
|
36
|
-
return tag.toLowerCase().replace(/[^a-z0-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function escapeXml(text: string): string {
|
|
40
|
-
return text
|
|
41
|
-
.replace(/&/g, "&")
|
|
42
|
-
.replace(/</g, "<")
|
|
43
|
-
.replace(/>/g, ">")
|
|
44
|
-
.replace(/"/g, """)
|
|
45
|
-
.replace(/'/g, "'");
|
|
47
|
+
return tag.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-{2,}/g, "-").slice(0, 64) || "sub-agent";
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
function createChildSessionId(parentId: string, tag: string): string {
|
|
50
|
+
export function createChildSessionId(parentId: string, tag: string): string {
|
|
49
51
|
let rand: string;
|
|
50
52
|
try {
|
|
51
53
|
if (typeof globalThis !== "undefined" && (globalThis as any).crypto?.randomUUID) {
|
|
@@ -64,24 +66,414 @@ function createChildSessionId(parentId: string, tag: string): string {
|
|
|
64
66
|
// `${parent}-sub-${tag}-${rand}` overflows (observed 65-72 chars → every
|
|
65
67
|
// sub-agent 400s with 0 usage). Truncate the parent portion to fit, keeping
|
|
66
68
|
// the tag + rand suffix intact for uniqueness/debuggability.
|
|
69
|
+
// Lineage binding: when truncation is needed, embed an 8-char hash of the
|
|
70
|
+
// full parent id so two distinct parents sharing a prefix still map to
|
|
71
|
+
// distinct child ids (previously they collided and shared provider routing).
|
|
67
72
|
const cleanTag = tag.toLowerCase().slice(0, 16);
|
|
68
73
|
const suffix = `-sub-${cleanTag}-${rand}`;
|
|
69
74
|
const maxParent = Math.max(0, 64 - suffix.length);
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
if (parentId.length <= maxParent) return `${parentId}${suffix}`;
|
|
76
|
+
const parentHash = hashSessionPart(parentId);
|
|
77
|
+
const baseLen = Math.max(0, maxParent - 9);
|
|
78
|
+
return `${parentId.slice(0, baseLen)}-${parentHash}${suffix}`;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
/** Builds a deterministic fixed-subagent session id that fits 64 chars. */
|
|
75
|
-
function createFixedChildSessionId(parentId: string, name: string): string {
|
|
82
|
+
export function createFixedChildSessionId(parentId: string, name: string): string {
|
|
76
83
|
const suffix = `-sub-${name}`;
|
|
77
84
|
if ((parentId + suffix).length <= 64) return parentId + suffix;
|
|
78
|
-
// Truncate parent first (preserves full tool name for debugging)
|
|
79
|
-
//
|
|
85
|
+
// Truncate parent first (preserves full tool name for debugging), but embed
|
|
86
|
+
// the parent hash so distinct parents sharing a prefix stay distinct.
|
|
87
|
+
// If still over (very long tool name), truncate the name tail as last resort.
|
|
80
88
|
const maxParent = Math.max(0, 64 - suffix.length);
|
|
89
|
+
if (maxParent > 9) {
|
|
90
|
+
const parentHash = hashSessionPart(parentId);
|
|
91
|
+
return `${parentId.slice(0, maxParent - 9)}-${parentHash}${suffix}`;
|
|
92
|
+
}
|
|
81
93
|
if (maxParent > 0) return parentId.slice(0, maxParent) + suffix;
|
|
82
94
|
return (`${parentId}-sub-${name}`).slice(0, 64);
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
/**
|
|
98
|
+
* True when `child` was derived from `parent` via the child-session helpers:
|
|
99
|
+
* either an untruncated `parent + suffix` prefix, or the embedded parent hash
|
|
100
|
+
* used when truncation was required. Used to validate lineage before pairing
|
|
101
|
+
* provider state across concurrent agents.
|
|
102
|
+
*/
|
|
103
|
+
export function isSessionDescendant(child: string, parent: string): boolean {
|
|
104
|
+
if (!child || !parent) return false;
|
|
105
|
+
if (child === parent) return true;
|
|
106
|
+
if (child.startsWith(parent + "-")) return true;
|
|
107
|
+
// Q-34: hash includes() is collision-prone; require delimited match to reduce spoofing. Explicit {rootSessionId,parentSessionId} lineage is Q-61 future.
|
|
108
|
+
const h = hashSessionPart(parent);
|
|
109
|
+
return child.includes(`-${h}-`) || child.endsWith(`-${h}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Live trace for one spawned worker, keyed by its TrackingID. */
|
|
113
|
+
export interface SubAgentTrace {
|
|
114
|
+
/** Sub-agent TrackingID: system-generated 32-char hex. Displayed as `NAME-{TrackingID}`. */
|
|
115
|
+
trackingId: string;
|
|
116
|
+
name: string;
|
|
117
|
+
sessionId: string;
|
|
118
|
+
parentSessionId: string;
|
|
119
|
+
status: "running" | "done" | "error";
|
|
120
|
+
task: string;
|
|
121
|
+
role?: string;
|
|
122
|
+
model?: string;
|
|
123
|
+
provider?: string;
|
|
124
|
+
turns: number;
|
|
125
|
+
usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
|
|
126
|
+
steps: SubAgentStep[];
|
|
127
|
+
text?: string;
|
|
128
|
+
error?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const subagentTraces = new Map<string, SubAgentTrace>();
|
|
132
|
+
const subagentListeners = new Map<string, Set<(trace: SubAgentTrace) => void>>();
|
|
133
|
+
|
|
134
|
+
/** Max trace keys held (LRU, evicts oldest whole trace on insert) (Q-30). */
|
|
135
|
+
export const MAX_SUBAGENT_TRACES = 500;
|
|
136
|
+
/** Trace TTL: 1h wall-clock, lazily expired on read (Q-30). */
|
|
137
|
+
export const SUBAGENT_TRACE_TTL_MS = 60 * 60 * 1000;
|
|
138
|
+
/** Max steps retained per trace; oldest steps are dropped beyond this (Q-30). */
|
|
139
|
+
export const MAX_SUBAGENT_STEPS_PER_TRACE = 100;
|
|
140
|
+
/** Live subscriptions auto-expire after 5min (Q-30). */
|
|
141
|
+
export const SUBAGENT_SUBSCRIBE_TTL_MS = 5 * 60 * 1000;
|
|
142
|
+
|
|
143
|
+
/** Last-write wall-clock per canonical trackingId (drives TTL; reads do not extend it). */
|
|
144
|
+
const traceTouchedAt = new Map<string, number>();
|
|
145
|
+
|
|
146
|
+
function canonicalOf(trace: SubAgentTrace): string {
|
|
147
|
+
return trace.trackingId;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function deleteTraceByCanonical(canonical: string): void {
|
|
151
|
+
for (const [key, t] of [...subagentTraces]) {
|
|
152
|
+
if (t.trackingId === canonical) subagentTraces.delete(key);
|
|
153
|
+
}
|
|
154
|
+
traceTouchedAt.delete(canonical);
|
|
155
|
+
subagentListeners.delete(canonical);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isTraceExpired(canonical: string, now = Date.now()): boolean {
|
|
159
|
+
const touched = traceTouchedAt.get(canonical);
|
|
160
|
+
if (touched === undefined) return false;
|
|
161
|
+
return now - touched > SUBAGENT_TRACE_TTL_MS;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function touchTrace(canonical: string, now = Date.now()): void {
|
|
165
|
+
traceTouchedAt.set(canonical, now);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Evicts expired traces, then oldest whole traces while over budget (Q-30).
|
|
170
|
+
* Called on every insert. Whole-trace eviction removes both the canonical
|
|
171
|
+
* key and its display-id alias.
|
|
172
|
+
*/
|
|
173
|
+
export function pruneTraces(now = Date.now()): void {
|
|
174
|
+
for (const canonical of [...new Set([...subagentTraces.values()].map((t) => t.trackingId))]) {
|
|
175
|
+
if (isTraceExpired(canonical, now)) deleteTraceByCanonical(canonical);
|
|
176
|
+
}
|
|
177
|
+
while (subagentTraces.size > MAX_SUBAGENT_TRACES) {
|
|
178
|
+
const oldestKey = subagentTraces.keys().next().value as string | undefined;
|
|
179
|
+
if (oldestKey === undefined) break;
|
|
180
|
+
const oldest = subagentTraces.get(oldestKey);
|
|
181
|
+
if (!oldest) {
|
|
182
|
+
subagentTraces.delete(oldestKey);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
deleteTraceByCanonical(oldest.trackingId);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Drops oldest steps beyond {@link MAX_SUBAGENT_STEPS_PER_TRACE} (Q-30). */
|
|
190
|
+
export function trimTraceSteps(trace: SubAgentTrace): void {
|
|
191
|
+
if (trace.steps.length > MAX_SUBAGENT_STEPS_PER_TRACE) {
|
|
192
|
+
trace.steps.splice(0, trace.steps.length - MAX_SUBAGENT_STEPS_PER_TRACE);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function insertTrace(trace: SubAgentTrace, displayId: string, now = Date.now()): void {
|
|
197
|
+
pruneTraces(now);
|
|
198
|
+
trimTraceSteps(trace);
|
|
199
|
+
subagentTraces.set(trace.trackingId, trace);
|
|
200
|
+
subagentTraces.set(displayId, trace);
|
|
201
|
+
touchTrace(trace.trackingId, now);
|
|
202
|
+
// Keep LRU order: newest at the end (re-set moves existing keys).
|
|
203
|
+
const canonical = subagentTraces.get(trace.trackingId);
|
|
204
|
+
if (canonical) {
|
|
205
|
+
subagentTraces.delete(trace.trackingId);
|
|
206
|
+
subagentTraces.set(trace.trackingId, canonical);
|
|
207
|
+
}
|
|
208
|
+
const alias = subagentTraces.get(displayId);
|
|
209
|
+
if (alias && displayId !== trace.trackingId) {
|
|
210
|
+
subagentTraces.delete(displayId);
|
|
211
|
+
subagentTraces.set(displayId, alias);
|
|
212
|
+
}
|
|
213
|
+
pruneTraces(now);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function lookupTrace(id: string): SubAgentTrace | undefined {
|
|
217
|
+
if (!id) return undefined;
|
|
218
|
+
const direct = subagentTraces.get(id);
|
|
219
|
+
if (direct) {
|
|
220
|
+
if (isTraceExpired(direct.trackingId)) {
|
|
221
|
+
deleteTraceByCanonical(direct.trackingId);
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
// LRU touch (order only; TTL is write-based).
|
|
225
|
+
subagentTraces.delete(id);
|
|
226
|
+
subagentTraces.set(id, direct);
|
|
227
|
+
return direct;
|
|
228
|
+
}
|
|
229
|
+
const lower = id.toLowerCase();
|
|
230
|
+
for (const [key, t] of subagentTraces) {
|
|
231
|
+
if (t.trackingId.toLowerCase() === lower) {
|
|
232
|
+
if (isTraceExpired(t.trackingId)) {
|
|
233
|
+
deleteTraceByCanonical(t.trackingId);
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
subagentTraces.delete(key);
|
|
237
|
+
subagentTraces.set(key, t);
|
|
238
|
+
return t;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function snapshotTrace(t: SubAgentTrace): SubAgentTrace {
|
|
245
|
+
return { ...t, steps: t.steps.map((s) => ({ ...s })), usage: t.usage ? { ...t.usage } : undefined };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function notifyTrace(trackingId: string): void {
|
|
249
|
+
const t = subagentTraces.get(trackingId);
|
|
250
|
+
if (!t) return;
|
|
251
|
+
const listeners = subagentListeners.get(trackingId);
|
|
252
|
+
if (!listeners || listeners.size === 0) return;
|
|
253
|
+
const snap = snapshotTrace(t);
|
|
254
|
+
for (const fn of [...listeners]) {
|
|
255
|
+
try { fn(snap); } catch {}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Returns a snapshot of one worker's trace by tracking id
|
|
261
|
+
* (`SUBAGENT-NAME-{32hex}` or the raw 32-hex suffix). Used by
|
|
262
|
+
* `agent.track(id)` for realtime inspection.
|
|
263
|
+
*
|
|
264
|
+
* Prefer {@link getSubAgentTraceScoped} when the parent session is known:
|
|
265
|
+
* this compat lookup matches any parent and can confuse sibling workers
|
|
266
|
+
* whose display names collide across sessions.
|
|
267
|
+
*/
|
|
268
|
+
export function getSubAgentTrace(id: string): SubAgentTrace | undefined {
|
|
269
|
+
const t = lookupTrace(id);
|
|
270
|
+
return t ? snapshotTrace(t) : undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Scoped trace lookup (Q-30, preferred): like {@link getSubAgentTrace} but
|
|
275
|
+
* only matches when `trace.parentSessionId === parentSessionId`. Use this
|
|
276
|
+
* from `Agent.track` (or any parent-bound caller) so truncated/aliased ids
|
|
277
|
+
* cannot leak across sessions.
|
|
278
|
+
*/
|
|
279
|
+
export function getSubAgentTraceScoped(id: string, parentSessionId: string): SubAgentTrace | undefined {
|
|
280
|
+
if (!id || !parentSessionId) return undefined;
|
|
281
|
+
const t = lookupTrace(id);
|
|
282
|
+
if (!t) return undefined;
|
|
283
|
+
if (t.parentSessionId !== parentSessionId) return undefined;
|
|
284
|
+
return snapshotTrace(t);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Lists tracking ids of known worker traces (mainly for `track` misses). */
|
|
288
|
+
export function listSubAgentTraceIds(): string[] {
|
|
289
|
+
pruneTraces();
|
|
290
|
+
return [...subagentTraces.keys()];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Subscribes to live updates of one worker trace. Returns an unsubscribe fn.
|
|
295
|
+
* Subscriptions auto-expire after 5min to avoid listener leaks (Q-30).
|
|
296
|
+
*/
|
|
297
|
+
export function subscribeToSubAgent(id: string, fn: (trace: SubAgentTrace) => void): () => void {
|
|
298
|
+
const t = lookupTrace(id);
|
|
299
|
+
const key = t ? t.trackingId : id;
|
|
300
|
+
let set = subagentListeners.get(key);
|
|
301
|
+
if (!set) {
|
|
302
|
+
set = new Set();
|
|
303
|
+
subagentListeners.set(key, set);
|
|
304
|
+
}
|
|
305
|
+
set.add(fn);
|
|
306
|
+
const timer = setTimeout(() => {
|
|
307
|
+
try { subagentListeners.get(key)?.delete(fn); } catch {}
|
|
308
|
+
}, SUBAGENT_SUBSCRIBE_TTL_MS);
|
|
309
|
+
try { (timer as unknown as { unref?: () => void }).unref?.(); } catch {}
|
|
310
|
+
let done = false;
|
|
311
|
+
return () => {
|
|
312
|
+
if (done) return;
|
|
313
|
+
done = true;
|
|
314
|
+
try { clearTimeout(timer); } catch {}
|
|
315
|
+
try { subagentListeners.get(key)?.delete(fn); } catch {}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Appends a step to a worker trace and notifies live subscribers. */
|
|
320
|
+
export function appendSubAgentStep(trackingId: string, step: SubAgentStep): void {
|
|
321
|
+
const t = subagentTraces.get(trackingId) ?? lookupTrace(trackingId);
|
|
322
|
+
if (!t) return;
|
|
323
|
+
if (isTraceExpired(t.trackingId)) {
|
|
324
|
+
deleteTraceByCanonical(t.trackingId);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
t.steps.push(step);
|
|
328
|
+
trimTraceSteps(t);
|
|
329
|
+
t.turns = Math.max(t.turns, step.turn);
|
|
330
|
+
touchTrace(t.trackingId);
|
|
331
|
+
notifyTrace(trackingId);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Budgets (Q-39): unenforced counters + guards. The loop owner wires
|
|
336
|
+
// per-turn charging; the spawn tool below enforces `maxTotalSubagents` and
|
|
337
|
+
// charges completed worker usage. Durations use the monotonic `nowMs()`
|
|
338
|
+
// clock; TTL timestamps above use wall-clock `Date.now()`.
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
function readBudget(parentAgent: Agent): Budget | undefined {
|
|
342
|
+
return (parentAgent as unknown as { budget?: Budget }).budget;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function readBudgetState(parentAgent: Agent): BudgetState | undefined {
|
|
346
|
+
return (parentAgent as unknown as { budgetState?: BudgetState }).budgetState;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function ensureBudgetState(parentAgent: Agent): BudgetState {
|
|
350
|
+
const host = parentAgent as unknown as { budgetState?: BudgetState };
|
|
351
|
+
if (!host.budgetState) {
|
|
352
|
+
host.budgetState = { spawned: 0, totalTokens: 0, totalCostUsd: 0, startedAtMs: nowMs() };
|
|
353
|
+
}
|
|
354
|
+
return host.budgetState;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function usageTokens(usage: unknown): number {
|
|
358
|
+
const u = usage as { totalTokens?: unknown; inputTokens?: unknown; outputTokens?: unknown } | undefined;
|
|
359
|
+
if (!u || typeof u !== "object") return 0;
|
|
360
|
+
if (typeof u.totalTokens === "number" && Number.isFinite(u.totalTokens)) return Math.max(0, Math.floor(u.totalTokens));
|
|
361
|
+
const inp = typeof u.inputTokens === "number" && Number.isFinite(u.inputTokens) ? u.inputTokens : 0;
|
|
362
|
+
const out = typeof u.outputTokens === "number" && Number.isFinite(u.outputTokens) ? u.outputTokens : 0;
|
|
363
|
+
return Math.max(0, Math.floor(inp + out));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function usageCostUsd(usage: unknown): number {
|
|
367
|
+
const cost = (usage as { cost?: { totalCost?: unknown } } | undefined)?.cost;
|
|
368
|
+
const v = cost?.totalCost;
|
|
369
|
+
return typeof v === "number" && Number.isFinite(v) ? Math.max(0, v) : 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Throws {@link BudgetExceededError} (with `partial` usage) when any budget
|
|
374
|
+
* limit is exceeded. Pure check — does not mutate state.
|
|
375
|
+
*/
|
|
376
|
+
export function checkBudget(
|
|
377
|
+
budget: Budget | undefined,
|
|
378
|
+
state: BudgetState | undefined,
|
|
379
|
+
opts?: { usage?: unknown; now?: number }
|
|
380
|
+
): void {
|
|
381
|
+
if (!budget) return;
|
|
382
|
+
const now = opts?.now ?? nowMs();
|
|
383
|
+
const spawned = state?.spawned ?? 0;
|
|
384
|
+
const totalTokens = (state?.totalTokens ?? 0) + (opts?.usage !== undefined ? usageTokens(opts.usage) : 0);
|
|
385
|
+
const totalCostUsd = (state?.totalCostUsd ?? 0) + (opts?.usage !== undefined ? usageCostUsd(opts.usage) : 0);
|
|
386
|
+
const elapsedMs = state ? Math.max(0, now - state.startedAtMs) : 0;
|
|
387
|
+
const partial = {
|
|
388
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens },
|
|
389
|
+
turns: 0 as number,
|
|
390
|
+
text: undefined as string | undefined,
|
|
391
|
+
};
|
|
392
|
+
if (budget.maxTotalSubagents !== undefined && spawned > budget.maxTotalSubagents) {
|
|
393
|
+
throw new BudgetExceededError(
|
|
394
|
+
`Sub-agent budget exceeded: spawned ${spawned} > maxTotalSubagents ${budget.maxTotalSubagents}.`,
|
|
395
|
+
{ context: { partial } }
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
if (budget.maxTotalTokens !== undefined && totalTokens > budget.maxTotalTokens) {
|
|
399
|
+
throw new BudgetExceededError(
|
|
400
|
+
`Token budget exceeded: ${totalTokens} > maxTotalTokens ${budget.maxTotalTokens}.`,
|
|
401
|
+
{ context: { partial } }
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
if (budget.maxCostUsd !== undefined && totalCostUsd > budget.maxCostUsd) {
|
|
405
|
+
throw new BudgetExceededError(
|
|
406
|
+
`Cost budget exceeded: $${totalCostUsd.toFixed(6)} > maxCostUsd $${budget.maxCostUsd}.`,
|
|
407
|
+
{ context: { partial } }
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
if (budget.maxDurationMs !== undefined && state && elapsedMs > budget.maxDurationMs) {
|
|
411
|
+
throw new BudgetExceededError(
|
|
412
|
+
`Duration budget exceeded: ${Math.round(elapsedMs)}ms > maxDurationMs ${budget.maxDurationMs}ms.`,
|
|
413
|
+
{ context: { partial } }
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Adds worker usage to the parent's budget counters (Q-39). Never throws;
|
|
420
|
+
* call {@link checkBudget} afterwards to enforce.
|
|
421
|
+
*/
|
|
422
|
+
export function chargeBudgetUsage(parentAgent: Agent, usage: unknown): BudgetState {
|
|
423
|
+
const state = ensureBudgetState(parentAgent);
|
|
424
|
+
state.totalTokens += usageTokens(usage);
|
|
425
|
+
state.totalCostUsd += usageCostUsd(usage);
|
|
426
|
+
return state;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Reserves `count` spawns against `maxTotalSubagents` (Q-39). Increments the
|
|
431
|
+
* parent's `budgetState.spawned` and throws {@link BudgetExceededError} when
|
|
432
|
+
* the reservation would exceed the budget. Call before spawning.
|
|
433
|
+
*/
|
|
434
|
+
export function reserveSpawn(parentAgent: Agent, count = 1): BudgetState {
|
|
435
|
+
const state = ensureBudgetState(parentAgent);
|
|
436
|
+
const budget = readBudget(parentAgent);
|
|
437
|
+
const next = state.spawned + Math.max(0, Math.floor(count));
|
|
438
|
+
if (budget?.maxTotalSubagents !== undefined && next > budget.maxTotalSubagents) {
|
|
439
|
+
throw new BudgetExceededError(
|
|
440
|
+
`Sub-agent budget exceeded: spawning ${count} would reach ${next} > maxTotalSubagents ${budget.maxTotalSubagents}.`,
|
|
441
|
+
{
|
|
442
|
+
context: {
|
|
443
|
+
partial: {
|
|
444
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: state.totalTokens },
|
|
445
|
+
turns: 0,
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
}
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
state.spawned = next;
|
|
452
|
+
checkBudget(budget, state);
|
|
453
|
+
return state;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// Single core loop note + streaming-loop guard (Q-44, partial).
|
|
458
|
+
// The canonical run/stream loops live in `loop.ts` + `agent.ts` (other
|
|
459
|
+
// owners) and are intentionally NOT duplicated here. This guard exists so
|
|
460
|
+
// future consolidation can branch on streaming handles without importing
|
|
461
|
+
// the loop.
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
/** Minimal shape of a streaming run handle (has subscribe + terminal promise). */
|
|
465
|
+
export interface StreamingLoopHandle {
|
|
466
|
+
on: (...args: unknown[]) => unknown;
|
|
467
|
+
result: (...args: unknown[]) => Promise<unknown>;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Type guard for streaming-loop handles (Q-44). */
|
|
471
|
+
export function isStreamingLoop(value: unknown): value is StreamingLoopHandle {
|
|
472
|
+
if (!value || typeof value !== "object") return false;
|
|
473
|
+
const r = value as Record<string, unknown>;
|
|
474
|
+
return typeof r["on"] === "function" && typeof r["result"] === "function";
|
|
475
|
+
}
|
|
476
|
+
|
|
85
477
|
function providerOf(modelStr: string | any | undefined): string | undefined {
|
|
86
478
|
if (!modelStr) return undefined;
|
|
87
479
|
try {
|
|
@@ -127,8 +519,8 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
|
|
|
127
519
|
? `Worker-available tools: ${poolNames.join(", ")}. Grant each worker ONLY the tools its task needs via the per-task tools list; omit it for no tools. `
|
|
128
520
|
: "No worker tools are available; omit the per-task tools list. ") +
|
|
129
521
|
timeoutNote + " " +
|
|
130
|
-
"Every entry in tasks MUST include all of: name (
|
|
131
|
-
"Example: {\"tasks\": [{\"name\": \"
|
|
522
|
+
"Every entry in tasks MUST include all of: name (UPPER-KEBAB tag), instructions (system prompt for the worker), task (concrete assignment for the worker). " +
|
|
523
|
+
"Example: {\"tasks\": [{\"name\": \"RESEARCH-ANALYST\", \"role\": \"memory market analyst\", \"instructions\": \"You are a memory market analyst. Return sourced findings only.\", \"task\": \"Research market pricing and supply constraints.\", \"tools\": [\"recent_news\"]}]}",
|
|
132
524
|
input: z.object({
|
|
133
525
|
tasks: z
|
|
134
526
|
.array(
|
|
@@ -136,7 +528,7 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
|
|
|
136
528
|
name: z
|
|
137
529
|
.string()
|
|
138
530
|
.optional()
|
|
139
|
-
.describe("Unique
|
|
531
|
+
.describe("Unique UPPER-KEBAB role tag dynamically derived from task (e.g. RESEARCH-ANALYST, MARKET-ANALYST, CODE-REVIEWER). Auto-generated when omitted."),
|
|
140
532
|
role: z
|
|
141
533
|
.string()
|
|
142
534
|
.optional()
|
|
@@ -168,12 +560,11 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
|
|
|
168
560
|
})
|
|
169
561
|
)
|
|
170
562
|
.min(1)
|
|
171
|
-
.max(maxSpawn)
|
|
172
563
|
.describe(`Array of sub-agents to spawn (max ${maxSpawn} per call) — each gets a personalized prompt and runs statelessly on the developer-configured model`),
|
|
173
564
|
}),
|
|
174
565
|
execute: async ({ tasks }, context) => {
|
|
175
566
|
if (!tasks || tasks.length === 0) {
|
|
176
|
-
return "Error: tasks array is empty. Provide at least one entry shaped like {\"name\": \"
|
|
567
|
+
return "Error: tasks array is empty. Provide at least one entry shaped like {\"name\": \"RESEARCH-ANALYST\", \"role\": \"memory market analyst\", \"instructions\": \"<system prompt>\", \"task\": \"<concrete assignment>\"}. Fix the arguments and call spawn_subagents again.";
|
|
177
568
|
}
|
|
178
569
|
const repaired = tasks.map((entry: Record<string, unknown>, index: number) => {
|
|
179
570
|
const rec = (entry ?? {}) as Record<string, unknown>;
|
|
@@ -188,7 +579,7 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
|
|
|
188
579
|
"";
|
|
189
580
|
const nameText =
|
|
190
581
|
(typeof rec["name"] === "string" && (rec["name"] as string).trim()) ||
|
|
191
|
-
`
|
|
582
|
+
`SUBAGENT-${index + 1}`;
|
|
192
583
|
const roleText =
|
|
193
584
|
typeof rec["role"] === "string" ? ((rec["role"] as string).trim() || undefined) : undefined;
|
|
194
585
|
const toolsList = Array.isArray(rec["tools"])
|
|
@@ -204,8 +595,8 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
|
|
|
204
595
|
return (
|
|
205
596
|
`Error: tasks[${invalid}].task is missing and could not be inferred. ` +
|
|
206
597
|
`Received keys: [${keys}]. ` +
|
|
207
|
-
`Each tasks[] entry MUST include task (concrete assignment) plus instructions (system prompt) and name (
|
|
208
|
-
`Example: {\"name\": \"
|
|
598
|
+
`Each tasks[] entry MUST include task (concrete assignment) plus instructions (system prompt) and name (UPPER-KEBAB tag). ` +
|
|
599
|
+
`Example: {\"name\": \"RESEARCH-ANALYST\", \"role\": \"memory market analyst\", \"instructions\": \"You are a memory market analyst.\", \"task\": \"Research market pricing.\"}. ` +
|
|
209
600
|
`Fix the entry and call spawn_subagents again.`
|
|
210
601
|
);
|
|
211
602
|
}
|
|
@@ -234,17 +625,32 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
|
|
|
234
625
|
|
|
235
626
|
// maxSpawn: trim extras safely — only the first N tasks run.
|
|
236
627
|
const limitedTasks = repaired.slice(0, effectiveMax);
|
|
628
|
+
// Q-39: reserve spawns against maxTotalSubagents before running.
|
|
629
|
+
reserveSpawn(parentAgent, limitedTasks.length);
|
|
630
|
+
// Q-42: batch run id for lineage; per-worker ids attach to metadata.
|
|
631
|
+
const batchRunId = newRunId();
|
|
632
|
+
const parentRunId =
|
|
633
|
+
(parentAgent as unknown as { currentRunId?: unknown; runId?: unknown }).currentRunId ??
|
|
634
|
+
(parentAgent as unknown as { runId?: unknown }).runId;
|
|
635
|
+
const rootRunId =
|
|
636
|
+
(parentAgent as unknown as { rootRunId?: unknown }).rootRunId ?? parentRunId;
|
|
237
637
|
const executedResults = await Promise.all(
|
|
238
638
|
limitedTasks.map(async (t) => {
|
|
239
|
-
const startTime =
|
|
639
|
+
const startTime = nowMs();
|
|
640
|
+
const workerTurnId = newTurnId();
|
|
240
641
|
let sanitized = sanitizeXmlTag(t.name);
|
|
241
642
|
let deduped = sanitized;
|
|
242
643
|
let suffix = 1;
|
|
243
644
|
while (seenNames.has(deduped)) {
|
|
244
|
-
deduped = `${sanitized}
|
|
645
|
+
deduped = `${sanitized}-${suffix++}`;
|
|
245
646
|
}
|
|
246
647
|
seenNames.add(deduped);
|
|
247
648
|
const subagentName = deduped;
|
|
649
|
+
// Stable tracking id: `SUBAGENT-NAME-{32hex}` addresses one worker
|
|
650
|
+
// among many concurrent ones for `agent.track(id)` + session logs.
|
|
651
|
+
const trackingId = createTrackingId();
|
|
652
|
+
const displayId = `${subagentName}-${trackingId}`;
|
|
653
|
+
let workerSessionId: string | undefined;
|
|
248
654
|
|
|
249
655
|
// Fixed developer-configured worker model — never LLM-choosable.
|
|
250
656
|
const chosenModel: any = parentAgent.subagentModel;
|
|
@@ -267,6 +673,108 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
|
|
|
267
673
|
const runWithModel = async (modelToUse: any, apiKey: string | undefined, baseUrl: string | undefined) => {
|
|
268
674
|
if (context?.signal?.aborted) throw new Error("Aborted before spawn");
|
|
269
675
|
const childSessionId = createChildSessionId(parentAgent.sessionId, subagentName);
|
|
676
|
+
workerSessionId = childSessionId;
|
|
677
|
+
// Register the live trace BEFORE the run so `agent.track(id)`
|
|
678
|
+
// sees every step in realtime, even while the worker runs.
|
|
679
|
+
const trace: SubAgentTrace = {
|
|
680
|
+
trackingId,
|
|
681
|
+
name: subagentName,
|
|
682
|
+
sessionId: childSessionId,
|
|
683
|
+
parentSessionId: parentAgent.sessionId,
|
|
684
|
+
status: "running",
|
|
685
|
+
task: t.task,
|
|
686
|
+
role: t.role,
|
|
687
|
+
turns: 0,
|
|
688
|
+
steps: [],
|
|
689
|
+
};
|
|
690
|
+
insertTrace(trace, displayId);
|
|
691
|
+
try {
|
|
692
|
+
const parentTraces = ((parentAgent as any).subagentTraces ??= {}) as Record<string, SubAgentTrace>;
|
|
693
|
+
parentTraces[trackingId] = trace;
|
|
694
|
+
} catch {}
|
|
695
|
+
const persistParent = () => {
|
|
696
|
+
try { (parentAgent as any).persistNow?.(); } catch {}
|
|
697
|
+
};
|
|
698
|
+
const onWorkerTurn = (turn: { turns: number; text?: string; thinking?: string; toolCalls?: Array<{ name: string }>; toolResults?: Array<{ name: string; isError?: boolean }> }) => {
|
|
699
|
+
try {
|
|
700
|
+
liveTurn = turn.turns;
|
|
701
|
+
// Finalize any partial streaming entry for this turn, then
|
|
702
|
+
// log structural steps (tool calls/results) as before.
|
|
703
|
+
const timestamp = Date.now();
|
|
704
|
+
const partialEntry = findPartial(liveTurn);
|
|
705
|
+
if (partialEntry) {
|
|
706
|
+
if (turn.text) partialEntry.text = turn.text;
|
|
707
|
+
if (turn.thinking) partialEntry.thinking = turn.thinking;
|
|
708
|
+
delete (partialEntry as any).partial;
|
|
709
|
+
} else if (turn.text) {
|
|
710
|
+
trace.steps.push({ turn: turn.turns, type: "assistant", text: turn.text.slice(0, 2000), timestamp });
|
|
711
|
+
}
|
|
712
|
+
for (const tc of turn.toolCalls ?? []) {
|
|
713
|
+
trace.steps.push({ turn: turn.turns, type: "tool_call", name: tc.name, timestamp });
|
|
714
|
+
}
|
|
715
|
+
for (const tr of turn.toolResults ?? []) {
|
|
716
|
+
trace.steps.push({ turn: turn.turns, type: "tool_result", name: tr.name, isError: tr.isError, timestamp });
|
|
717
|
+
}
|
|
718
|
+
trace.turns = Math.max(trace.turns, turn.turns);
|
|
719
|
+
trimTraceSteps(trace);
|
|
720
|
+
touchTrace(trace.trackingId);
|
|
721
|
+
notifyTrace(trackingId);
|
|
722
|
+
persistParent();
|
|
723
|
+
} catch {}
|
|
724
|
+
};
|
|
725
|
+
// Realtime thinking/text: the worker is streamed internally and
|
|
726
|
+
// every delta lands in the trace (partial entry, updated in
|
|
727
|
+
// place) so `agent.track(id)` shows generation as it happens —
|
|
728
|
+
// not just at turn end. Deltas also ride to the parent stream via
|
|
729
|
+
// `context.onSubagentEvent` (streaming parent runs only).
|
|
730
|
+
let liveTurn = 1;
|
|
731
|
+
let partialText = "";
|
|
732
|
+
let partialThinking = "";
|
|
733
|
+
const findPartial = (turn: number): SubAgentStep | undefined => {
|
|
734
|
+
for (let i = trace.steps.length - 1; i >= 0; i--) {
|
|
735
|
+
const s = trace.steps[i]!;
|
|
736
|
+
if (s.turn === turn && s.partial) return s;
|
|
737
|
+
}
|
|
738
|
+
return undefined;
|
|
739
|
+
};
|
|
740
|
+
const forwardDelta = (delta?: string, thinkingDelta?: string) => {
|
|
741
|
+
try {
|
|
742
|
+
context?.onSubagentEvent?.({
|
|
743
|
+
trackingId: displayId,
|
|
744
|
+
delta,
|
|
745
|
+
thinkingDelta,
|
|
746
|
+
partialText: partialText || undefined,
|
|
747
|
+
partialThinking: partialThinking || undefined,
|
|
748
|
+
});
|
|
749
|
+
} catch {}
|
|
750
|
+
};
|
|
751
|
+
const onWorkerDelta = (delta?: string, thinkingDelta?: string) => {
|
|
752
|
+
try {
|
|
753
|
+
if (delta) {
|
|
754
|
+
partialText = (partialText + delta).slice(-10000);
|
|
755
|
+
let entry = findPartial(liveTurn);
|
|
756
|
+
if (!entry) {
|
|
757
|
+
entry = { turn: liveTurn, type: "assistant", text: "", partial: true, timestamp: Date.now() };
|
|
758
|
+
trace.steps.push(entry);
|
|
759
|
+
}
|
|
760
|
+
entry.text = partialText;
|
|
761
|
+
entry.timestamp = Date.now();
|
|
762
|
+
}
|
|
763
|
+
if (thinkingDelta) {
|
|
764
|
+
partialThinking = (partialThinking + thinkingDelta).slice(-10000);
|
|
765
|
+
let entry = findPartial(liveTurn);
|
|
766
|
+
if (!entry) {
|
|
767
|
+
entry = { turn: liveTurn, type: "assistant", text: "", partial: true, timestamp: Date.now() };
|
|
768
|
+
trace.steps.push(entry);
|
|
769
|
+
}
|
|
770
|
+
entry.thinking = partialThinking;
|
|
771
|
+
entry.timestamp = Date.now();
|
|
772
|
+
}
|
|
773
|
+
trimTraceSteps(trace);
|
|
774
|
+
notifyTrace(trackingId);
|
|
775
|
+
forwardDelta(delta, thinkingDelta);
|
|
776
|
+
} catch {}
|
|
777
|
+
};
|
|
270
778
|
// Grant ONLY the Main Agent-selected subset from the developer pool. Unknown names are dropped.
|
|
271
779
|
// Matching is case/format-insensitive (same rules as tool execution): the model may emit
|
|
272
780
|
// "RECENT_NEWS" or "recent news" for a registered "recent_news" tool.
|
|
@@ -299,6 +807,12 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
|
|
|
299
807
|
headers: parentAgent.customHeaders,
|
|
300
808
|
maxTurns: parentAgent.maxTurns,
|
|
301
809
|
});
|
|
810
|
+
// Lineage binding: child records its parent session so persisted
|
|
811
|
+
// snapshots and routing can tell siblings apart even when ids
|
|
812
|
+
// are truncated to 64 chars.
|
|
813
|
+
try {
|
|
814
|
+
(subAgent as any).parentSessionId = parentAgent.sessionId;
|
|
815
|
+
} catch {}
|
|
302
816
|
let abortListener: (() => void) | null = null;
|
|
303
817
|
// Per-worker controller: the parent signal alone cannot stop a
|
|
304
818
|
// worker that hits its own timeout, so the timeout path aborts
|
|
@@ -333,7 +847,14 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
|
|
|
333
847
|
}, workerTimeout);
|
|
334
848
|
})
|
|
335
849
|
: null;
|
|
336
|
-
|
|
850
|
+
// Streamed internally (consumed to completion): same final result
|
|
851
|
+
// as run(), but text/thinking deltas flow into the trace live.
|
|
852
|
+
const workerStream = subAgent.stream(t.task, { signal: workerController.signal, onTurn: onWorkerTurn } as any);
|
|
853
|
+
const textListener = (e: any) => onWorkerDelta(e.delta, undefined);
|
|
854
|
+
const thinkingListener = (e: any) => onWorkerDelta(undefined, e.thinkingDelta);
|
|
855
|
+
workerStream.on("text_delta", textListener);
|
|
856
|
+
workerStream.on("thinking_delta", thinkingListener);
|
|
857
|
+
const taskPromise = workerStream.result();
|
|
337
858
|
try {
|
|
338
859
|
const racers: Promise<unknown>[] = [taskPromise as unknown as Promise<unknown>];
|
|
339
860
|
if (abortPromise) racers.push(abortPromise);
|
|
@@ -342,6 +863,9 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
|
|
|
342
863
|
return res;
|
|
343
864
|
} finally {
|
|
344
865
|
if (timeoutId) clearTimeout(timeoutId);
|
|
866
|
+
try { workerStream.cancel(); } catch {}
|
|
867
|
+
try { workerStream.off("text_delta", textListener); } catch {}
|
|
868
|
+
try { workerStream.off("thinking_delta", thinkingListener); } catch {}
|
|
345
869
|
if (abortListener && parentSignal) {
|
|
346
870
|
try { parentSignal.removeEventListener("abort", abortListener as any); } catch {}
|
|
347
871
|
}
|
|
@@ -352,14 +876,47 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
|
|
|
352
876
|
try {
|
|
353
877
|
const res: any = await runWithModel(chosenModel, apiKeyToUse, baseUrlToUse);
|
|
354
878
|
|
|
355
|
-
const durationMs =
|
|
879
|
+
const durationMs = Math.max(0, nowMs() - startTime);
|
|
356
880
|
const rawText = (res as any).text;
|
|
357
881
|
const subagentText = typeof rawText === "string" && rawText.trim().length > 0
|
|
358
882
|
? rawText
|
|
359
883
|
: `[Sub-agent ${subagentName} produced no output (empty response).]`;
|
|
360
884
|
|
|
885
|
+
const live = subagentTraces.get(trackingId);
|
|
886
|
+
if (live) {
|
|
887
|
+
live.status = "done";
|
|
888
|
+
live.model = (res as any).model;
|
|
889
|
+
live.provider = (res as any).provider;
|
|
890
|
+
live.turns = (res as any).turns ?? live.turns;
|
|
891
|
+
live.usage = (res as any).usage
|
|
892
|
+
? { inputTokens: (res as any).usage.inputTokens ?? 0, outputTokens: (res as any).usage.outputTokens ?? 0, totalTokens: (res as any).usage.totalTokens ?? 0 }
|
|
893
|
+
: live.usage;
|
|
894
|
+
live.text = subagentText;
|
|
895
|
+
trimTraceSteps(live);
|
|
896
|
+
touchTrace(live.trackingId);
|
|
897
|
+
notifyTrace(trackingId);
|
|
898
|
+
}
|
|
899
|
+
try { (parentAgent as any).persistNow?.(); } catch {}
|
|
900
|
+
// Q-39: charge completed worker usage, then enforce token/cost/duration budgets.
|
|
901
|
+
try { chargeBudgetUsage(parentAgent, (res as any).usage); } catch {}
|
|
902
|
+
try {
|
|
903
|
+
checkBudget(readBudget(parentAgent), readBudgetState(parentAgent), { usage: undefined });
|
|
904
|
+
} catch (budgetErr) {
|
|
905
|
+
const be = budgetErr as { context?: { partial?: Record<string, unknown> } };
|
|
906
|
+
try {
|
|
907
|
+
if (be && typeof be === "object" && be.context?.partial) {
|
|
908
|
+
(be.context.partial as Record<string, unknown>)["text"] = subagentText;
|
|
909
|
+
(be.context.partial as Record<string, unknown>)["turns"] = (res as any).turns ?? 0;
|
|
910
|
+
(be.context.partial as Record<string, unknown>)["usage"] = (res as any).usage;
|
|
911
|
+
}
|
|
912
|
+
} catch {}
|
|
913
|
+
throw budgetErr;
|
|
914
|
+
}
|
|
361
915
|
const metadata: SubAgentExecutionMetadata = {
|
|
362
916
|
name: subagentName,
|
|
917
|
+
trackingId: displayId,
|
|
918
|
+
sessionId: workerSessionId,
|
|
919
|
+
parentSessionId: parentAgent.sessionId,
|
|
363
920
|
role: t.role,
|
|
364
921
|
task: t.task,
|
|
365
922
|
model: (res as any).model,
|
|
@@ -373,15 +930,38 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
|
|
|
373
930
|
thinking: (res as any).thinking,
|
|
374
931
|
toolCalls: (res as any).toolCalls,
|
|
375
932
|
raw: (res as any).raw,
|
|
933
|
+
steps: live ? live.steps.map((s) => ({ ...s })) : undefined,
|
|
376
934
|
isError: false,
|
|
377
935
|
};
|
|
378
|
-
|
|
936
|
+
// Q-42 lineage (untyped: response.ts is owned elsewhere) + Q-43 provider meta passthrough.
|
|
937
|
+
try {
|
|
938
|
+
const m = metadata as unknown as Record<string, unknown>;
|
|
939
|
+
m["runId"] = newRunId();
|
|
940
|
+
m["turnId"] = workerTurnId;
|
|
941
|
+
if (parentRunId !== undefined) m["parentRunId"] = parentRunId;
|
|
942
|
+
if (rootRunId !== undefined) m["rootRunId"] = rootRunId;
|
|
943
|
+
else if (parentRunId !== undefined) m["rootRunId"] = parentRunId;
|
|
944
|
+
else m["rootRunId"] = batchRunId;
|
|
945
|
+
if ((res as any).meta !== undefined) m["meta"] = (res as any).meta;
|
|
946
|
+
} catch {}
|
|
947
|
+
return { name: subagentName, trackingId: displayId, text: subagentText, metadata };
|
|
379
948
|
} catch (err: any) {
|
|
380
|
-
|
|
949
|
+
if (err && (err as { code?: unknown }).code === "budget_exceeded") throw err;
|
|
950
|
+
const durationMs = Math.max(0, nowMs() - startTime);
|
|
381
951
|
const errorMessage = err?.message || String(err);
|
|
382
952
|
const prov = attemptedProvider || providerOf(chosenModelStrForProvider) || providerOf(chosenModel as any) || "unknown";
|
|
953
|
+
const live = subagentTraces.get(trackingId);
|
|
954
|
+
if (live) {
|
|
955
|
+
live.status = "error";
|
|
956
|
+
live.error = errorMessage;
|
|
957
|
+
notifyTrace(trackingId);
|
|
958
|
+
}
|
|
959
|
+
try { (parentAgent as any).persistNow?.(); } catch {}
|
|
383
960
|
const metadata: SubAgentExecutionMetadata = {
|
|
384
961
|
name: subagentName,
|
|
962
|
+
trackingId: displayId,
|
|
963
|
+
sessionId: workerSessionId,
|
|
964
|
+
parentSessionId: parentAgent.sessionId,
|
|
385
965
|
role: t.role,
|
|
386
966
|
task: t.task,
|
|
387
967
|
model: typeof chosenModel === "string" ? chosenModel : ((chosenModel as any)?.model ?? (chosenModel as any)?.id ?? "unknown"),
|
|
@@ -390,10 +970,20 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
|
|
|
390
970
|
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
391
971
|
turns: 0,
|
|
392
972
|
text: `Error executing sub-agent ${t.name}: ${errorMessage}`,
|
|
973
|
+
steps: live ? live.steps.map((s) => ({ ...s })) : undefined,
|
|
393
974
|
isError: true,
|
|
394
975
|
error: errorMessage,
|
|
395
976
|
};
|
|
396
|
-
|
|
977
|
+
try {
|
|
978
|
+
const m = metadata as unknown as Record<string, unknown>;
|
|
979
|
+
m["runId"] = newRunId();
|
|
980
|
+
m["turnId"] = workerTurnId;
|
|
981
|
+
if (parentRunId !== undefined) m["parentRunId"] = parentRunId;
|
|
982
|
+
if (rootRunId !== undefined) m["rootRunId"] = rootRunId;
|
|
983
|
+
else if (parentRunId !== undefined) m["rootRunId"] = parentRunId;
|
|
984
|
+
else m["rootRunId"] = batchRunId;
|
|
985
|
+
} catch {}
|
|
986
|
+
return { name: subagentName, trackingId: displayId, text: `Error executing sub-agent ${t.name}: ${errorMessage}`, metadata };
|
|
397
987
|
}
|
|
398
988
|
})
|
|
399
989
|
);
|
|
@@ -436,7 +1026,7 @@ export function agentToTool(
|
|
|
436
1026
|
const rawName =
|
|
437
1027
|
("name" in input && (input as any).name) ||
|
|
438
1028
|
agentInstance.name ||
|
|
439
|
-
`
|
|
1029
|
+
`sub-agent-${Math.random().toString(36).slice(2, 7)}`;
|
|
440
1030
|
const name = sanitizeToolName(rawName);
|
|
441
1031
|
const description =
|
|
442
1032
|
("description" in input && (input as any).description) ||
|
|
@@ -450,14 +1040,120 @@ export function agentToTool(
|
|
|
450
1040
|
task: z.string().describe("The detailed instruction or prompt to pass to this sub-agent."),
|
|
451
1041
|
}),
|
|
452
1042
|
execute: async ({ task }, ctx) => {
|
|
453
|
-
const startTime =
|
|
1043
|
+
const startTime = nowMs();
|
|
1044
|
+
const trackingId = createTrackingId();
|
|
1045
|
+
const displayId = `${name}-${trackingId}`;
|
|
1046
|
+
// Q-42 lineage for fixed-delegation calls.
|
|
1047
|
+
const callRunId = newRunId();
|
|
1048
|
+
const callTurnId = newTurnId();
|
|
454
1049
|
try {
|
|
455
1050
|
const parentSessionId = ctx?.sessionId || agentInstance.sessionId || "session";
|
|
456
1051
|
const subSessionId = createFixedChildSessionId(parentSessionId, sanitizeToolName(name));
|
|
457
|
-
const
|
|
458
|
-
|
|
1052
|
+
const trace: SubAgentTrace = {
|
|
1053
|
+
trackingId,
|
|
1054
|
+
name,
|
|
1055
|
+
sessionId: subSessionId,
|
|
1056
|
+
parentSessionId,
|
|
1057
|
+
status: "running",
|
|
1058
|
+
task,
|
|
1059
|
+
turns: 0,
|
|
1060
|
+
steps: [],
|
|
1061
|
+
};
|
|
1062
|
+
insertTrace(trace, displayId);
|
|
1063
|
+
const onWorkerTurn = (turn: { turns: number; text?: string; thinking?: string; toolCalls?: Array<{ name: string }>; toolResults?: Array<{ name: string; isError?: boolean }> }) => {
|
|
1064
|
+
try {
|
|
1065
|
+
liveTurn = turn.turns;
|
|
1066
|
+
const timestamp = Date.now();
|
|
1067
|
+
const partialEntry = findPartial(liveTurn);
|
|
1068
|
+
if (partialEntry) {
|
|
1069
|
+
if (turn.text) partialEntry.text = turn.text;
|
|
1070
|
+
if (turn.thinking) partialEntry.thinking = turn.thinking;
|
|
1071
|
+
delete (partialEntry as any).partial;
|
|
1072
|
+
} else if (turn.text) {
|
|
1073
|
+
trace.steps.push({ turn: turn.turns, type: "assistant", text: turn.text.slice(0, 2000), timestamp });
|
|
1074
|
+
}
|
|
1075
|
+
for (const tc of turn.toolCalls ?? []) trace.steps.push({ turn: turn.turns, type: "tool_call", name: tc.name, timestamp });
|
|
1076
|
+
for (const tr of turn.toolResults ?? []) trace.steps.push({ turn: turn.turns, type: "tool_result", name: tr.name, isError: tr.isError, timestamp });
|
|
1077
|
+
trace.turns = Math.max(trace.turns, turn.turns);
|
|
1078
|
+
trimTraceSteps(trace);
|
|
1079
|
+
touchTrace(trace.trackingId);
|
|
1080
|
+
notifyTrace(trackingId);
|
|
1081
|
+
} catch {}
|
|
1082
|
+
};
|
|
1083
|
+
let liveTurn = 1;
|
|
1084
|
+
let partialText = "";
|
|
1085
|
+
let partialThinking = "";
|
|
1086
|
+
const findPartial = (turn: number): SubAgentStep | undefined => {
|
|
1087
|
+
for (let i = trace.steps.length - 1; i >= 0; i--) {
|
|
1088
|
+
const s = trace.steps[i]!;
|
|
1089
|
+
if (s.turn === turn && s.partial) return s;
|
|
1090
|
+
}
|
|
1091
|
+
return undefined;
|
|
1092
|
+
};
|
|
1093
|
+
const onWorkerDelta = (delta?: string, thinkingDelta?: string) => {
|
|
1094
|
+
try {
|
|
1095
|
+
if (delta) {
|
|
1096
|
+
partialText = (partialText + delta).slice(-10000);
|
|
1097
|
+
let entry = findPartial(liveTurn);
|
|
1098
|
+
if (!entry) {
|
|
1099
|
+
entry = { turn: liveTurn, type: "assistant", text: "", partial: true, timestamp: Date.now() };
|
|
1100
|
+
trace.steps.push(entry);
|
|
1101
|
+
}
|
|
1102
|
+
entry.text = partialText;
|
|
1103
|
+
entry.timestamp = Date.now();
|
|
1104
|
+
}
|
|
1105
|
+
if (thinkingDelta) {
|
|
1106
|
+
partialThinking = (partialThinking + thinkingDelta).slice(-10000);
|
|
1107
|
+
let entry = findPartial(liveTurn);
|
|
1108
|
+
if (!entry) {
|
|
1109
|
+
entry = { turn: liveTurn, type: "assistant", text: "", partial: true, timestamp: Date.now() };
|
|
1110
|
+
trace.steps.push(entry);
|
|
1111
|
+
}
|
|
1112
|
+
entry.thinking = partialThinking;
|
|
1113
|
+
entry.timestamp = Date.now();
|
|
1114
|
+
}
|
|
1115
|
+
trimTraceSteps(trace);
|
|
1116
|
+
notifyTrace(trackingId);
|
|
1117
|
+
try {
|
|
1118
|
+
ctx?.onSubagentEvent?.({
|
|
1119
|
+
trackingId: displayId,
|
|
1120
|
+
delta,
|
|
1121
|
+
thinkingDelta,
|
|
1122
|
+
partialText: partialText || undefined,
|
|
1123
|
+
partialThinking: partialThinking || undefined,
|
|
1124
|
+
});
|
|
1125
|
+
} catch {}
|
|
1126
|
+
} catch {}
|
|
1127
|
+
};
|
|
1128
|
+
const workerStream = agentInstance.stream(task, { signal: ctx?.signal, sessionId: subSessionId, onTurn: onWorkerTurn } as any);
|
|
1129
|
+
const textListener = (e: any) => onWorkerDelta(e.delta, undefined);
|
|
1130
|
+
const thinkingListener = (e: any) => onWorkerDelta(undefined, e.thinkingDelta);
|
|
1131
|
+
workerStream.on("text_delta", textListener);
|
|
1132
|
+
workerStream.on("thinking_delta", thinkingListener);
|
|
1133
|
+
let response: any;
|
|
1134
|
+
try {
|
|
1135
|
+
response = await workerStream.result();
|
|
1136
|
+
} finally {
|
|
1137
|
+
try { workerStream.off("text_delta", textListener); } catch {}
|
|
1138
|
+
try { workerStream.off("thinking_delta", thinkingListener); } catch {}
|
|
1139
|
+
}
|
|
1140
|
+
const durationMs = Math.max(0, nowMs() - startTime);
|
|
1141
|
+
const live = subagentTraces.get(trackingId);
|
|
1142
|
+
if (live) {
|
|
1143
|
+
live.status = "done";
|
|
1144
|
+
live.model = response.model;
|
|
1145
|
+
live.provider = response.provider;
|
|
1146
|
+
live.turns = response.turns ?? live.turns;
|
|
1147
|
+
live.text = response.text;
|
|
1148
|
+
trimTraceSteps(live);
|
|
1149
|
+
touchTrace(live.trackingId);
|
|
1150
|
+
notifyTrace(trackingId);
|
|
1151
|
+
}
|
|
459
1152
|
const metadata: SubAgentExecutionMetadata = {
|
|
460
1153
|
name,
|
|
1154
|
+
trackingId: displayId,
|
|
1155
|
+
sessionId: subSessionId,
|
|
1156
|
+
parentSessionId,
|
|
461
1157
|
task,
|
|
462
1158
|
model: response.model,
|
|
463
1159
|
provider: response.provider,
|
|
@@ -470,8 +1166,15 @@ export function agentToTool(
|
|
|
470
1166
|
thinking: response.thinking,
|
|
471
1167
|
toolCalls: response.toolCalls,
|
|
472
1168
|
raw: response.raw,
|
|
1169
|
+
steps: live ? live.steps.map((s) => ({ ...s })) : undefined,
|
|
473
1170
|
isError: false,
|
|
474
1171
|
};
|
|
1172
|
+
try {
|
|
1173
|
+
const m = metadata as unknown as Record<string, unknown>;
|
|
1174
|
+
m["runId"] = callRunId;
|
|
1175
|
+
m["turnId"] = callTurnId;
|
|
1176
|
+
if (response.meta !== undefined) m["meta"] = response.meta;
|
|
1177
|
+
} catch {}
|
|
475
1178
|
const wrapper: any = {
|
|
476
1179
|
xml: response.text,
|
|
477
1180
|
_subagentMetadata: [metadata],
|
|
@@ -479,10 +1182,19 @@ export function agentToTool(
|
|
|
479
1182
|
};
|
|
480
1183
|
return wrapper;
|
|
481
1184
|
} catch (err: any) {
|
|
482
|
-
const durationMs =
|
|
1185
|
+
const durationMs = Math.max(0, nowMs() - startTime);
|
|
483
1186
|
const msg = err?.message || String(err);
|
|
1187
|
+
const live = subagentTraces.get(trackingId);
|
|
1188
|
+
if (live) {
|
|
1189
|
+
live.status = "error";
|
|
1190
|
+
live.error = msg;
|
|
1191
|
+
trimTraceSteps(live);
|
|
1192
|
+
touchTrace(live.trackingId);
|
|
1193
|
+
notifyTrace(trackingId);
|
|
1194
|
+
}
|
|
484
1195
|
const metadata: SubAgentExecutionMetadata = {
|
|
485
1196
|
name,
|
|
1197
|
+
trackingId: displayId,
|
|
486
1198
|
task,
|
|
487
1199
|
model: "unknown",
|
|
488
1200
|
provider: "unknown",
|
|
@@ -490,9 +1202,15 @@ export function agentToTool(
|
|
|
490
1202
|
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
491
1203
|
turns: 0,
|
|
492
1204
|
text: `Error in ${name}: ${msg}`,
|
|
1205
|
+
steps: live ? live.steps.map((s) => ({ ...s })) : undefined,
|
|
493
1206
|
isError: true,
|
|
494
1207
|
error: msg,
|
|
495
1208
|
};
|
|
1209
|
+
try {
|
|
1210
|
+
const m = metadata as unknown as Record<string, unknown>;
|
|
1211
|
+
m["runId"] = callRunId;
|
|
1212
|
+
m["turnId"] = callTurnId;
|
|
1213
|
+
} catch {}
|
|
496
1214
|
const wrapper: any = {
|
|
497
1215
|
xml: `Error in ${name}: ${msg}`,
|
|
498
1216
|
_subagentMetadata: [metadata],
|
|
@@ -521,7 +1239,7 @@ export function buildAgentTools(
|
|
|
521
1239
|
let finalName = t.name;
|
|
522
1240
|
let n = 1;
|
|
523
1241
|
while (tools[finalName]) {
|
|
524
|
-
finalName = `${t.name}
|
|
1242
|
+
finalName = `${t.name}-${n++}`;
|
|
525
1243
|
}
|
|
526
1244
|
tools[finalName] = { ...t, name: finalName };
|
|
527
1245
|
}
|