@juspay/neurolink 10.5.3 → 10.6.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/CHANGELOG.md +6 -0
- package/dist/agent/agent.js +39 -8
- package/dist/agent/agentNetwork.js +62 -20
- package/dist/agent/agentToolRegistrar.d.ts +48 -0
- package/dist/agent/agentToolRegistrar.js +336 -0
- package/dist/agent/index.d.ts +3 -0
- package/dist/agent/index.js +4 -0
- package/dist/agent/isolatedAgentRunner.d.ts +73 -0
- package/dist/agent/isolatedAgentRunner.js +946 -0
- package/dist/agent/structuredRecovery.d.ts +30 -0
- package/dist/agent/structuredRecovery.js +104 -0
- package/dist/browser/neurolink.min.js +414 -398
- package/dist/cli/loop/optionsSchema.d.ts +1 -1
- package/dist/core/baseProvider.d.ts +8 -0
- package/dist/core/baseProvider.js +40 -19
- package/dist/core/modules/GenerationHandler.d.ts +2 -6
- package/dist/core/modules/GenerationHandler.js +10 -1
- package/dist/core/toolExecutionRecorder.d.ts +76 -0
- package/dist/core/toolExecutionRecorder.js +261 -0
- package/dist/evaluation/contextBuilder.js +6 -6
- package/dist/lib/agent/agent.js +39 -8
- package/dist/lib/agent/agentNetwork.js +62 -20
- package/dist/lib/agent/agentToolRegistrar.d.ts +48 -0
- package/dist/lib/agent/agentToolRegistrar.js +337 -0
- package/dist/lib/agent/index.d.ts +3 -0
- package/dist/lib/agent/index.js +4 -0
- package/dist/lib/agent/isolatedAgentRunner.d.ts +73 -0
- package/dist/lib/agent/isolatedAgentRunner.js +947 -0
- package/dist/lib/agent/structuredRecovery.d.ts +30 -0
- package/dist/lib/agent/structuredRecovery.js +105 -0
- package/dist/lib/core/baseProvider.d.ts +8 -0
- package/dist/lib/core/baseProvider.js +40 -19
- package/dist/lib/core/modules/GenerationHandler.d.ts +2 -6
- package/dist/lib/core/modules/GenerationHandler.js +10 -1
- package/dist/lib/core/toolExecutionRecorder.d.ts +76 -0
- package/dist/lib/core/toolExecutionRecorder.js +262 -0
- package/dist/lib/evaluation/contextBuilder.js +6 -6
- package/dist/lib/models/modelRegistry.d.ts +23 -0
- package/dist/lib/models/modelRegistry.js +62 -1
- package/dist/lib/models/modelResolver.js +1 -0
- package/dist/lib/neurolink.d.ts +80 -1
- package/dist/lib/neurolink.js +199 -37
- package/dist/lib/providers/amazonBedrock.js +15 -2
- package/dist/lib/providers/anthropic.js +23 -11
- package/dist/lib/providers/googleAiStudio.js +8 -1
- package/dist/lib/providers/googleNativeGemini3.d.ts +2 -0
- package/dist/lib/providers/googleNativeGemini3.js +8 -1
- package/dist/lib/providers/googleVertex.js +56 -16
- package/dist/lib/providers/openaiChatCompletionsClient.js +18 -5
- package/dist/lib/tasks/autoresearchTaskExecutor.js +7 -6
- package/dist/lib/tasks/taskExecutor.js +2 -5
- package/dist/lib/types/agentNetwork.d.ts +23 -8
- package/dist/lib/types/generate.d.ts +62 -5
- package/dist/lib/types/index.d.ts +1 -0
- package/dist/lib/types/index.js +1 -0
- package/dist/lib/types/isolatedAgent.d.ts +365 -0
- package/dist/lib/types/isolatedAgent.js +12 -0
- package/dist/lib/types/model.d.ts +8 -0
- package/dist/lib/types/stream.d.ts +3 -1
- package/dist/lib/types/task.d.ts +3 -3
- package/dist/lib/utils/logger.d.ts +12 -3
- package/dist/lib/utils/logger.js +11 -3
- package/dist/lib/utils/transformationUtils.d.ts +14 -0
- package/dist/lib/utils/transformationUtils.js +33 -10
- package/dist/lib/voice/livekit/realtimeVoiceAgent.js +6 -3
- package/dist/models/modelRegistry.d.ts +23 -0
- package/dist/models/modelRegistry.js +62 -1
- package/dist/models/modelResolver.js +1 -0
- package/dist/neurolink.d.ts +80 -1
- package/dist/neurolink.js +199 -37
- package/dist/providers/amazonBedrock.js +15 -2
- package/dist/providers/anthropic.js +23 -11
- package/dist/providers/googleAiStudio.js +8 -1
- package/dist/providers/googleNativeGemini3.d.ts +2 -0
- package/dist/providers/googleNativeGemini3.js +8 -1
- package/dist/providers/googleVertex.js +56 -16
- package/dist/providers/openaiChatCompletionsClient.js +18 -5
- package/dist/tasks/autoresearchTaskExecutor.js +7 -6
- package/dist/tasks/taskExecutor.js +2 -5
- package/dist/types/agentNetwork.d.ts +23 -8
- package/dist/types/generate.d.ts +62 -5
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/isolatedAgent.d.ts +365 -0
- package/dist/types/isolatedAgent.js +11 -0
- package/dist/types/model.d.ts +8 -0
- package/dist/types/stream.d.ts +3 -1
- package/dist/types/task.d.ts +3 -3
- package/dist/utils/logger.d.ts +12 -3
- package/dist/utils/logger.js +11 -3
- package/dist/utils/transformationUtils.d.ts +14 -0
- package/dist/utils/transformationUtils.js +33 -10
- package/dist/voice/livekit/realtimeVoiceAgent.js +6 -3
- package/package.json +3 -2
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runIsolatedAgent() — the production sub-agent runner.
|
|
3
|
+
*
|
|
4
|
+
* One function replacing what every consumer hand-rolled on top of
|
|
5
|
+
* generate(): a worker-mode instance (memory off, orchestration off,
|
|
6
|
+
* observability inherited), a tool-using research pass under the turn budget
|
|
7
|
+
* (wrap-up nudge + stall watchdog + honest stopReason), an extraction pass
|
|
8
|
+
* that ALWAYS runs tools-off on its own timeout, a structured-recovery
|
|
9
|
+
* ladder with corrective re-asks, a delivery guarantee (non-empty execution
|
|
10
|
+
* records never produce an empty result), abort chaining, a lifecycle event
|
|
11
|
+
* stream, leashed legs with TTL'd resume handles, and mechanical
|
|
12
|
+
* waste-signature detection.
|
|
13
|
+
*
|
|
14
|
+
* Behavioral contract: docs/plans/2026-07-27-isolated-agent-runner-rfc.md.
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from "crypto";
|
|
17
|
+
import { logger } from "../utils/logger.js";
|
|
18
|
+
import { recoverStructuredData } from "./structuredRecovery.js";
|
|
19
|
+
// ── Defaults (all overridable via options) ─────────────────────────────────
|
|
20
|
+
const DEFAULT_HANDLE_TTL_MS = 600_000; // 10 min
|
|
21
|
+
const DEFAULT_EXTRACTION_TIMEOUT_MS = 60_000;
|
|
22
|
+
const DEFAULT_EXTRACTION_MAX_RETRIES = 2;
|
|
23
|
+
const DEFAULT_MAX_STEPS = 10; // AgentDefinition's documented default
|
|
24
|
+
const DEFAULT_DUPLICATE_CALL_LIMIT = 2;
|
|
25
|
+
const DEFAULT_EMPTY_RESULT_STREAK_LIMIT = 3;
|
|
26
|
+
const DEFAULT_ERROR_STREAK_LIMIT = 3;
|
|
27
|
+
const DEFAULT_NO_NEW_RESULTS_LIMIT = 8;
|
|
28
|
+
/** Evidence text budget for the extraction prompt. */
|
|
29
|
+
const EXTRACTION_EVIDENCE_MAX_CHARS = 120_000;
|
|
30
|
+
/** Bounded per-event result summary for onEvent listeners. */
|
|
31
|
+
const EVENT_RESULT_SUMMARY_CHARS = 400;
|
|
32
|
+
// ── Session registry (leashed handles) ─────────────────────────────────────
|
|
33
|
+
const sessions = new Map();
|
|
34
|
+
/**
|
|
35
|
+
* True when the SAME host + caller session already has an open (live)
|
|
36
|
+
* leashed handle for this agent definition.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately narrow: a handle belongs to the conversation that opened it,
|
|
39
|
+
* so the conflict only exists when the caller-supplied `sessionId` matches —
|
|
40
|
+
* a multi-tenant host must never refuse thread B because thread A has an
|
|
41
|
+
* in-flight leg (thread B cannot see, continue, or legitimately steer that
|
|
42
|
+
* handle). Callers without a stable sessionId get no conflict detection;
|
|
43
|
+
* cross-session concurrency is the delegation pool's job.
|
|
44
|
+
*/
|
|
45
|
+
export function hasOpenIsolatedAgentHandle(host, definitionId, callerSessionId) {
|
|
46
|
+
if (callerSessionId === undefined) {
|
|
47
|
+
return { open: false };
|
|
48
|
+
}
|
|
49
|
+
for (const session of sessions.values()) {
|
|
50
|
+
if (session.host === host &&
|
|
51
|
+
session.definition.id === definitionId &&
|
|
52
|
+
session.callerSessionId === callerSessionId &&
|
|
53
|
+
!session.tombstone) {
|
|
54
|
+
return { open: true, handle: session.handle };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { open: false };
|
|
58
|
+
}
|
|
59
|
+
// ── Small helpers ──────────────────────────────────────────────────────────
|
|
60
|
+
function emitEvent(options, event) {
|
|
61
|
+
if (!options?.onEvent) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
options.onEvent(event);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Listener errors never break the run.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function stableStringify(value) {
|
|
72
|
+
try {
|
|
73
|
+
if (value === null || typeof value !== "object") {
|
|
74
|
+
return JSON.stringify(value) ?? "undefined";
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
78
|
+
}
|
|
79
|
+
const record = value;
|
|
80
|
+
const keys = Object.keys(record).sort();
|
|
81
|
+
return `{${keys
|
|
82
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(record[k])}`)
|
|
83
|
+
.join(",")}}`;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return String(value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function isEmptyResultText(resultText) {
|
|
90
|
+
const trimmed = resultText.trim();
|
|
91
|
+
return (trimmed === "" ||
|
|
92
|
+
trimmed === "null" ||
|
|
93
|
+
trimmed === "undefined" ||
|
|
94
|
+
trimmed === "[]" ||
|
|
95
|
+
trimmed === "{}" ||
|
|
96
|
+
trimmed === '""');
|
|
97
|
+
}
|
|
98
|
+
function summarizeParams(params, maxChars = 120) {
|
|
99
|
+
const text = stableStringify(params);
|
|
100
|
+
return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
|
|
101
|
+
}
|
|
102
|
+
function addUsage(total, delta) {
|
|
103
|
+
if (!delta) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
total.input += delta.input ?? 0;
|
|
107
|
+
total.output += delta.output ?? 0;
|
|
108
|
+
total.total += delta.total ?? 0;
|
|
109
|
+
}
|
|
110
|
+
function chatMessage(role, content) {
|
|
111
|
+
return { id: randomUUID(), role, content };
|
|
112
|
+
}
|
|
113
|
+
function isAbortLikeError(error) {
|
|
114
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
115
|
+
const name = error instanceof Error ? error.name : "";
|
|
116
|
+
return name === "AbortError" || /abort/i.test(message);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A leg is "cut short" — and therefore resumable in leashed mode — only when
|
|
120
|
+
* something bounded it: the turn budget (`time-limit`), the step cap, the
|
|
121
|
+
* leg's own tool-call budget, or a waste trip. A generate that returned
|
|
122
|
+
* normally WITHOUT a stopReason is a finished turn: several provider loops
|
|
123
|
+
* (e.g. the chat-completions/LiteLLM path) never set `stopReason`, and
|
|
124
|
+
* inferring "unfinished" from its absence would leave those paths
|
|
125
|
+
* `in_progress` forever (and mislabel every healthy non-leashed run partial).
|
|
126
|
+
*/
|
|
127
|
+
function isLegCutShort(legOutcome) {
|
|
128
|
+
const stop = legOutcome.result?.stopReason;
|
|
129
|
+
return (stop === "time-limit" ||
|
|
130
|
+
stop === "step-cap" ||
|
|
131
|
+
legOutcome.budgetTripped ||
|
|
132
|
+
legOutcome.wasteSignals.length > 0);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Mechanical digest of a run's execution records: which tools ran, ok/failed
|
|
136
|
+
* counts, and bounded excerpts of successful payloads. The delivery
|
|
137
|
+
* guarantee's last line of defense — a non-empty execution record can never
|
|
138
|
+
* produce an empty result.
|
|
139
|
+
*/
|
|
140
|
+
export function buildMechanicalDigest(records) {
|
|
141
|
+
const toolsRun = {};
|
|
142
|
+
for (const record of records) {
|
|
143
|
+
const entry = (toolsRun[record.toolName] ??= {
|
|
144
|
+
calls: 0,
|
|
145
|
+
ok: 0,
|
|
146
|
+
failed: 0,
|
|
147
|
+
});
|
|
148
|
+
entry.calls++;
|
|
149
|
+
if (record.isError) {
|
|
150
|
+
entry.failed++;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
entry.ok++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const excerpts = records
|
|
157
|
+
.filter((r) => !r.isError && !isEmptyResultText(r.resultText))
|
|
158
|
+
.slice(-10)
|
|
159
|
+
.map((r) => ({
|
|
160
|
+
toolName: r.toolName,
|
|
161
|
+
params: summarizeParams(r.params),
|
|
162
|
+
resultText: r.resultText.length > 2000
|
|
163
|
+
? `${r.resultText.slice(0, 2000)}…`
|
|
164
|
+
: r.resultText,
|
|
165
|
+
}));
|
|
166
|
+
return { kind: "mechanical-digest", toolsRun, excerpts };
|
|
167
|
+
}
|
|
168
|
+
// ── Waste detection ────────────────────────────────────────────────────────
|
|
169
|
+
function checkWasteSignatures(record, session, state, thresholds) {
|
|
170
|
+
const tripped = [];
|
|
171
|
+
const duplicateLimit = thresholds?.duplicateCallLimit ?? DEFAULT_DUPLICATE_CALL_LIMIT;
|
|
172
|
+
const emptyLimit = thresholds?.emptyResultStreakLimit ?? DEFAULT_EMPTY_RESULT_STREAK_LIMIT;
|
|
173
|
+
const errorLimit = thresholds?.errorStreakLimit ?? DEFAULT_ERROR_STREAK_LIMIT;
|
|
174
|
+
const noNewLimit = thresholds?.noNewResultsLimit ?? DEFAULT_NO_NEW_RESULTS_LIMIT;
|
|
175
|
+
// Duplicate call hash (same tool + same normalized params in this run)
|
|
176
|
+
const callHash = `${record.toolName}:${stableStringify(record.params)}`;
|
|
177
|
+
const seenCount = (session.seenCallHashes.get(callHash) ?? 0) + 1;
|
|
178
|
+
session.seenCallHashes.set(callHash, seenCount);
|
|
179
|
+
if (seenCount > duplicateLimit) {
|
|
180
|
+
tripped.push(`duplicate-call: ${record.toolName} called ${seenCount}× with identical params`);
|
|
181
|
+
}
|
|
182
|
+
// Consecutive empty results
|
|
183
|
+
if (!record.isError && isEmptyResultText(record.resultText)) {
|
|
184
|
+
state.emptyStreak++;
|
|
185
|
+
if (state.emptyStreak >= emptyLimit) {
|
|
186
|
+
tripped.push(`empty-results: ${state.emptyStreak} consecutive empty/zero results`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
state.emptyStreak = 0;
|
|
191
|
+
}
|
|
192
|
+
// Consecutive errors
|
|
193
|
+
if (record.isError) {
|
|
194
|
+
state.errorStreak++;
|
|
195
|
+
if (state.errorStreak >= errorLimit) {
|
|
196
|
+
tripped.push(`error-streak: ${state.errorStreak} consecutive failures`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
state.errorStreak = 0;
|
|
201
|
+
}
|
|
202
|
+
// Budget burn without new distinct results
|
|
203
|
+
const resultHash = stableStringify(record.resultText);
|
|
204
|
+
if (session.seenResultHashes.has(resultHash) ||
|
|
205
|
+
record.isError ||
|
|
206
|
+
isEmptyResultText(record.resultText)) {
|
|
207
|
+
state.callsSinceNewResult++;
|
|
208
|
+
if (state.callsSinceNewResult >= noNewLimit) {
|
|
209
|
+
tripped.push(`no-new-results: ${state.callsSinceNewResult} calls without a new distinct result`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
session.seenResultHashes.add(resultHash);
|
|
214
|
+
state.callsSinceNewResult = 0;
|
|
215
|
+
}
|
|
216
|
+
return tripped;
|
|
217
|
+
}
|
|
218
|
+
// ── Extraction ─────────────────────────────────────────────────────────────
|
|
219
|
+
function buildEvidenceBlock(records) {
|
|
220
|
+
const lines = [];
|
|
221
|
+
let budget = EXTRACTION_EVIDENCE_MAX_CHARS;
|
|
222
|
+
// Newest evidence is the most relevant — walk backwards, then restore order.
|
|
223
|
+
for (let i = records.length - 1; i >= 0 && budget > 0; i--) {
|
|
224
|
+
const r = records[i];
|
|
225
|
+
const line = `[${i + 1}] ${r.toolName}(${summarizeParams(r.params, 300)}) → ${r.isError ? "ERROR" : "ok"} (${r.durationMs}ms)\n${r.resultText}`;
|
|
226
|
+
budget -= line.length;
|
|
227
|
+
if (budget < 0 && lines.length > 0) {
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
lines.push(line);
|
|
231
|
+
}
|
|
232
|
+
return lines.reverse().join("\n\n");
|
|
233
|
+
}
|
|
234
|
+
async function runExtractionPass(args) {
|
|
235
|
+
const extraction = args.definition.extraction;
|
|
236
|
+
if (!extraction) {
|
|
237
|
+
return { errors: [] };
|
|
238
|
+
}
|
|
239
|
+
const maxRetries = extraction.maxRetries ?? DEFAULT_EXTRACTION_MAX_RETRIES;
|
|
240
|
+
const timeoutMs = extraction.timeoutMs ?? DEFAULT_EXTRACTION_TIMEOUT_MS;
|
|
241
|
+
// Phase-level deadline so the composed wall-clock is bounded by ONE knob:
|
|
242
|
+
// callers deriving outer ceilings need a single number, not
|
|
243
|
+
// (maxRetries + 1) × timeoutMs arithmetic.
|
|
244
|
+
const totalTimeoutMs = extraction.totalTimeoutMs ?? (maxRetries + 1) * timeoutMs;
|
|
245
|
+
const extractionDeadline = Date.now() + totalTimeoutMs;
|
|
246
|
+
const wireSchema = extraction.wireSchema ?? extraction.schema;
|
|
247
|
+
emitEvent(args.runOptions, {
|
|
248
|
+
type: "phase",
|
|
249
|
+
runId: args.runId,
|
|
250
|
+
agentId: args.definition.id,
|
|
251
|
+
timestamp: Date.now(),
|
|
252
|
+
phase: "extraction",
|
|
253
|
+
});
|
|
254
|
+
const evidence = buildEvidenceBlock(args.records);
|
|
255
|
+
const basePrompt = [
|
|
256
|
+
"You are the extraction pass of a two-pass research agent. Produce ONLY the requested JSON — no prose, no markdown fencing.",
|
|
257
|
+
extraction.shapeDoc ? `Required JSON shape:\n${extraction.shapeDoc}` : "",
|
|
258
|
+
evidence
|
|
259
|
+
? `Evidence — tool executions from the research pass:\n${evidence}`
|
|
260
|
+
: "No tool executions were recorded.",
|
|
261
|
+
args.researchContent ? `Researcher's notes:\n${args.researchContent}` : "",
|
|
262
|
+
]
|
|
263
|
+
.filter(Boolean)
|
|
264
|
+
.join("\n\n");
|
|
265
|
+
let prompt = basePrompt;
|
|
266
|
+
let attachSchema = true;
|
|
267
|
+
const allErrors = [];
|
|
268
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
269
|
+
if (args.abortSignal?.aborted) {
|
|
270
|
+
allErrors.push("extraction aborted by parent signal");
|
|
271
|
+
return { errors: allErrors };
|
|
272
|
+
}
|
|
273
|
+
const remainingMs = extractionDeadline - Date.now();
|
|
274
|
+
if (remainingMs <= 0) {
|
|
275
|
+
allErrors.push(`extraction phase deadline reached (${totalTimeoutMs}ms total)`);
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
const attemptTimeoutMs = Math.min(timeoutMs, remainingMs);
|
|
279
|
+
let result;
|
|
280
|
+
try {
|
|
281
|
+
result = await args.worker.generate({
|
|
282
|
+
input: { text: prompt },
|
|
283
|
+
systemPrompt: "You convert research evidence into schema-valid JSON. Output only JSON.",
|
|
284
|
+
provider: args.runOptions?.overrides?.provider ?? args.definition.provider,
|
|
285
|
+
model: args.runOptions?.overrides?.model ?? args.definition.model,
|
|
286
|
+
disableTools: true,
|
|
287
|
+
// The extraction pass runs on its OWN timeout — never carved out of
|
|
288
|
+
// the research budget.
|
|
289
|
+
timeout: attemptTimeoutMs,
|
|
290
|
+
turnTimeoutMs: attemptTimeoutMs,
|
|
291
|
+
maxSteps: 1,
|
|
292
|
+
...(args.runOptions?.overrides?.maxTokens !== undefined && {
|
|
293
|
+
maxTokens: args.runOptions.overrides.maxTokens,
|
|
294
|
+
}),
|
|
295
|
+
...(attachSchema && { schema: wireSchema }),
|
|
296
|
+
...(args.abortSignal && { abortSignal: args.abortSignal }),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
if (args.abortSignal?.aborted || isAbortLikeError(error)) {
|
|
301
|
+
allErrors.push("extraction aborted");
|
|
302
|
+
return { errors: allErrors };
|
|
303
|
+
}
|
|
304
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
305
|
+
allErrors.push(`extraction generate failed: ${message}`);
|
|
306
|
+
if (attachSchema) {
|
|
307
|
+
// Providers can reject the wire schema (constrained-decoding
|
|
308
|
+
// complexity limits) — retry without it; the local ladder still
|
|
309
|
+
// validates.
|
|
310
|
+
attachSchema = false;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
addUsage(args.usage, result.usage);
|
|
316
|
+
const recovered = recoverStructuredData({
|
|
317
|
+
content: result.content,
|
|
318
|
+
structuredData: result.structuredData,
|
|
319
|
+
schema: extraction.schema,
|
|
320
|
+
coerce: extraction.coerce,
|
|
321
|
+
});
|
|
322
|
+
if (recovered.data !== undefined) {
|
|
323
|
+
return {
|
|
324
|
+
data: recovered.data,
|
|
325
|
+
source: recovered.source,
|
|
326
|
+
errors: allErrors,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
allErrors.push(...recovered.errors);
|
|
330
|
+
// Corrective re-ask: carry the validation errors and the shape doc.
|
|
331
|
+
prompt = [
|
|
332
|
+
basePrompt,
|
|
333
|
+
`Your previous output failed validation:\n${recovered.errors.join("\n")}`,
|
|
334
|
+
extraction.shapeDoc
|
|
335
|
+
? `Fix the output to match this shape exactly:\n${extraction.shapeDoc}`
|
|
336
|
+
: "Fix the output to be valid JSON matching the required schema exactly.",
|
|
337
|
+
].join("\n\n");
|
|
338
|
+
}
|
|
339
|
+
return { errors: allErrors };
|
|
340
|
+
}
|
|
341
|
+
// ── Research leg ───────────────────────────────────────────────────────────
|
|
342
|
+
async function runResearchLeg(session, prompt) {
|
|
343
|
+
const { definition, options, worker, runId } = session;
|
|
344
|
+
const overrides = options.overrides;
|
|
345
|
+
const leg = options.leg;
|
|
346
|
+
const legStart = Date.now();
|
|
347
|
+
const legRecords = [];
|
|
348
|
+
const wasteSignals = [];
|
|
349
|
+
const wasteState = {
|
|
350
|
+
emptyStreak: 0,
|
|
351
|
+
errorStreak: 0,
|
|
352
|
+
callsSinceNewResult: 0,
|
|
353
|
+
};
|
|
354
|
+
// Internal controller: waste trips and tool-call budgets end the leg early;
|
|
355
|
+
// the parent signal chains in so an aborted parent stops the leg too.
|
|
356
|
+
const legController = new AbortController();
|
|
357
|
+
let abortedByParent = false;
|
|
358
|
+
let budgetTripped = false;
|
|
359
|
+
const onParentAbort = () => {
|
|
360
|
+
abortedByParent = true;
|
|
361
|
+
legController.abort();
|
|
362
|
+
};
|
|
363
|
+
if (options.abortSignal) {
|
|
364
|
+
if (options.abortSignal.aborted) {
|
|
365
|
+
onParentAbort();
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
options.abortSignal.addEventListener("abort", onParentAbort, {
|
|
369
|
+
once: true,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const onRecord = (record) => {
|
|
374
|
+
legRecords.push(record);
|
|
375
|
+
session.allRecords.push(record);
|
|
376
|
+
session.spentToolCalls++;
|
|
377
|
+
emitEvent(options, {
|
|
378
|
+
type: "tool_call",
|
|
379
|
+
runId,
|
|
380
|
+
agentId: definition.id,
|
|
381
|
+
timestamp: record.startedAt,
|
|
382
|
+
toolName: record.toolName,
|
|
383
|
+
params: record.params,
|
|
384
|
+
});
|
|
385
|
+
emitEvent(options, {
|
|
386
|
+
type: "tool_result",
|
|
387
|
+
runId,
|
|
388
|
+
agentId: definition.id,
|
|
389
|
+
timestamp: Date.now(),
|
|
390
|
+
toolName: record.toolName,
|
|
391
|
+
resultSummary: record.resultText.slice(0, EVENT_RESULT_SUMMARY_CHARS),
|
|
392
|
+
isError: record.isError,
|
|
393
|
+
});
|
|
394
|
+
const tripped = checkWasteSignatures(record, session, wasteState, options.waste);
|
|
395
|
+
if (tripped.length > 0) {
|
|
396
|
+
wasteSignals.push(...tripped.filter((t) => !wasteSignals.includes(t)));
|
|
397
|
+
emitEvent(options, {
|
|
398
|
+
type: "waste",
|
|
399
|
+
runId,
|
|
400
|
+
agentId: definition.id,
|
|
401
|
+
timestamp: Date.now(),
|
|
402
|
+
wasteSignals: [...wasteSignals],
|
|
403
|
+
});
|
|
404
|
+
legController.abort();
|
|
405
|
+
}
|
|
406
|
+
if (leg?.budgetToolCalls !== undefined &&
|
|
407
|
+
legRecords.length >= leg.budgetToolCalls &&
|
|
408
|
+
!legController.signal.aborted) {
|
|
409
|
+
budgetTripped = true;
|
|
410
|
+
legController.abort();
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
// Leg wall-clock budget rides the REAL turn budget machinery: the model
|
|
414
|
+
// gets the wrap-up nudge and consolidates — never a bare wall-clock kill.
|
|
415
|
+
const turnBudgetCandidates = [overrides?.turnTimeoutMs, leg?.budgetMs].filter((v) => typeof v === "number" && v > 0);
|
|
416
|
+
const turnTimeoutMs = turnBudgetCandidates.length > 0
|
|
417
|
+
? Math.min(...turnBudgetCandidates)
|
|
418
|
+
: undefined;
|
|
419
|
+
const generateOptions = {
|
|
420
|
+
input: { text: prompt },
|
|
421
|
+
systemPrompt: definition.instructions,
|
|
422
|
+
provider: overrides?.provider ?? definition.provider,
|
|
423
|
+
model: overrides?.model ?? definition.model,
|
|
424
|
+
...(definition.temperature !== undefined && {
|
|
425
|
+
temperature: definition.temperature,
|
|
426
|
+
}),
|
|
427
|
+
...(definition.tools &&
|
|
428
|
+
definition.tools.length > 0 && { toolFilter: definition.tools }),
|
|
429
|
+
maxSteps: overrides?.maxSteps ?? definition.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
430
|
+
...(overrides?.maxTokens !== undefined && {
|
|
431
|
+
maxTokens: overrides.maxTokens,
|
|
432
|
+
}),
|
|
433
|
+
...(turnTimeoutMs !== undefined && { turnTimeoutMs }),
|
|
434
|
+
...(overrides?.stallTimeoutMs !== undefined && {
|
|
435
|
+
stallTimeoutMs: overrides.stallTimeoutMs,
|
|
436
|
+
}),
|
|
437
|
+
...(overrides?.wrapupTimeLeadMs !== undefined && {
|
|
438
|
+
wrapupTimeLeadMs: overrides.wrapupTimeLeadMs,
|
|
439
|
+
}),
|
|
440
|
+
abortSignal: legController.signal,
|
|
441
|
+
requestId: `${runId}-leg${session.legIndex}`,
|
|
442
|
+
toolExecutionCapture: {
|
|
443
|
+
...(options.capture ?? {}),
|
|
444
|
+
onRecord,
|
|
445
|
+
},
|
|
446
|
+
...(session.history.length > 0 && {
|
|
447
|
+
conversationMessages: session.history,
|
|
448
|
+
}),
|
|
449
|
+
context: {
|
|
450
|
+
agentId: definition.id,
|
|
451
|
+
runId,
|
|
452
|
+
...(options.toolContext ?? {}),
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
let result;
|
|
456
|
+
let researchError;
|
|
457
|
+
try {
|
|
458
|
+
result = await worker.generate(generateOptions);
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
if (abortedByParent || legController.signal.aborted) {
|
|
462
|
+
// Early leg end (parent abort / waste trip / tool budget) — the
|
|
463
|
+
// records survive; extraction still runs from them.
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
researchError = error instanceof Error ? error.message : String(error);
|
|
467
|
+
emitEvent(options, {
|
|
468
|
+
type: "error",
|
|
469
|
+
runId,
|
|
470
|
+
agentId: definition.id,
|
|
471
|
+
timestamp: Date.now(),
|
|
472
|
+
error: researchError,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
if (options.abortSignal) {
|
|
478
|
+
options.abortSignal.removeEventListener("abort", onParentAbort);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (result?.stopReason === "time-limit") {
|
|
482
|
+
emitEvent(options, {
|
|
483
|
+
type: "wrapup",
|
|
484
|
+
runId,
|
|
485
|
+
agentId: definition.id,
|
|
486
|
+
timestamp: Date.now(),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
if (result?.usage) {
|
|
490
|
+
addUsage(session.usage, result.usage);
|
|
491
|
+
}
|
|
492
|
+
// Maintain the worker's conversation for continuation legs.
|
|
493
|
+
session.history.push(chatMessage("user", prompt));
|
|
494
|
+
if (result?.content) {
|
|
495
|
+
session.history.push(chatMessage("assistant", result.content));
|
|
496
|
+
}
|
|
497
|
+
else if (legRecords.length > 0 || wasteSignals.length > 0) {
|
|
498
|
+
// An early leg end (waste trip / tool budget / parent abort) killed the
|
|
499
|
+
// generate mid-step, so there is no assistant text. Push a synthetic
|
|
500
|
+
// assistant message from what the runner already knows — otherwise the
|
|
501
|
+
// resumed worker (and askNextPlan, which replays this history) is blind
|
|
502
|
+
// to its own prior tool activity and re-runs the same queries, which the
|
|
503
|
+
// run-scoped duplicate-call signature then trips on immediately.
|
|
504
|
+
const header = wasteSignals.length > 0
|
|
505
|
+
? `[leg ended early — waste signals: ${wasteSignals.join("; ")}]`
|
|
506
|
+
: "[leg ended early before a written summary]";
|
|
507
|
+
session.history.push(chatMessage("assistant", `${header}\nTool activity this leg:\n${buildDelta(legRecords).join("\n")}`));
|
|
508
|
+
}
|
|
509
|
+
const durationMs = Date.now() - legStart;
|
|
510
|
+
session.spentMs += durationMs;
|
|
511
|
+
return {
|
|
512
|
+
result,
|
|
513
|
+
researchError,
|
|
514
|
+
legRecords,
|
|
515
|
+
wasteSignals,
|
|
516
|
+
toolCalls: legRecords.length,
|
|
517
|
+
durationMs,
|
|
518
|
+
aborted: abortedByParent,
|
|
519
|
+
budgetTripped,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
/** Ask the worker for its own 1–2 sentence next-step statement (leg wrap). */
|
|
523
|
+
async function askNextPlan(session) {
|
|
524
|
+
try {
|
|
525
|
+
const result = await session.worker.generate({
|
|
526
|
+
input: {
|
|
527
|
+
text: "In 1-2 sentences, state the next concrete step you would take to finish this investigation. Output only the statement.",
|
|
528
|
+
},
|
|
529
|
+
systemPrompt: session.definition.instructions,
|
|
530
|
+
provider: session.options.overrides?.provider ?? session.definition.provider,
|
|
531
|
+
model: session.options.overrides?.model ?? session.definition.model,
|
|
532
|
+
disableTools: true,
|
|
533
|
+
maxSteps: 1,
|
|
534
|
+
timeout: 20_000,
|
|
535
|
+
turnTimeoutMs: 20_000,
|
|
536
|
+
conversationMessages: session.history,
|
|
537
|
+
...(session.options.abortSignal && {
|
|
538
|
+
abortSignal: session.options.abortSignal,
|
|
539
|
+
}),
|
|
540
|
+
});
|
|
541
|
+
addUsage(session.usage, result.usage);
|
|
542
|
+
return result.content?.trim() || undefined;
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// ── Outcome assembly ───────────────────────────────────────────────────────
|
|
549
|
+
function resolveBudget(session) {
|
|
550
|
+
const budgetMs = session.options.leg?.budgetMs;
|
|
551
|
+
return {
|
|
552
|
+
spentMs: session.spentMs,
|
|
553
|
+
remainingMs: budgetMs !== undefined ? Math.max(0, budgetMs - session.spentMs) : 0,
|
|
554
|
+
spentToolCalls: session.spentToolCalls,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function buildDelta(legRecords) {
|
|
558
|
+
return legRecords.map((r) => `${r.toolName}(${summarizeParams(r.params, 80)}) → ${r.isError
|
|
559
|
+
? "error"
|
|
560
|
+
: isEmptyResultText(r.resultText)
|
|
561
|
+
? "empty"
|
|
562
|
+
: `ok (${r.resultText.length} chars)`}`);
|
|
563
|
+
}
|
|
564
|
+
async function finalizeOutcome(session, legOutcome, startedAt) {
|
|
565
|
+
const { definition, options, runId } = session;
|
|
566
|
+
const result = legOutcome.result;
|
|
567
|
+
const researchStop = legOutcome.aborted
|
|
568
|
+
? "aborted"
|
|
569
|
+
: (result?.stopReason ??
|
|
570
|
+
(legOutcome.researchError ? "provider-error" : undefined));
|
|
571
|
+
const researchContent = result?.content ?? "";
|
|
572
|
+
const records = session.allRecords;
|
|
573
|
+
// Extraction ALWAYS runs when configured — even when the research generate
|
|
574
|
+
// died, it extracts from the execution records instead of losing the run.
|
|
575
|
+
// Parent abort is the one exception: a cancelled run stops cleanly.
|
|
576
|
+
let data;
|
|
577
|
+
let extractionSource;
|
|
578
|
+
let extractionError;
|
|
579
|
+
if (definition.extraction && !options.abortSignal?.aborted) {
|
|
580
|
+
const extracted = await runExtractionPass({
|
|
581
|
+
worker: session.worker,
|
|
582
|
+
definition,
|
|
583
|
+
runOptions: options,
|
|
584
|
+
runId,
|
|
585
|
+
records,
|
|
586
|
+
researchContent,
|
|
587
|
+
usage: session.usage,
|
|
588
|
+
abortSignal: options.abortSignal,
|
|
589
|
+
});
|
|
590
|
+
data = extracted.data;
|
|
591
|
+
extractionSource = extracted.source;
|
|
592
|
+
if (data === undefined && extracted.errors.length > 0) {
|
|
593
|
+
extractionError = extracted.errors.slice(-4).join(" | ");
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const hasEvidence = records.length > 0 || researchContent.trim().length > 0;
|
|
597
|
+
// "Research finished cleanly" is derived from the leg's own control flow,
|
|
598
|
+
// not from provider stopReason vocabulary: a missing stopReason (e.g. the
|
|
599
|
+
// chat-completions loop never sets one) is a clean finish, while any
|
|
600
|
+
// runner-imposed cut (time-limit / step-cap / tool budget / waste) is not.
|
|
601
|
+
const researchCleanFinish = !isLegCutShort(legOutcome) &&
|
|
602
|
+
(researchStop === "completed" || researchStop === undefined);
|
|
603
|
+
let status;
|
|
604
|
+
if (legOutcome.aborted) {
|
|
605
|
+
status = hasEvidence ? "partial" : "error";
|
|
606
|
+
}
|
|
607
|
+
else if (data !== undefined) {
|
|
608
|
+
status = researchCleanFinish ? "completed" : "partial";
|
|
609
|
+
}
|
|
610
|
+
else if (definition.extraction) {
|
|
611
|
+
if (!hasEvidence) {
|
|
612
|
+
status = legOutcome.researchError ? "error" : "insufficient_data";
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
// Delivery guarantee: mechanical digest instead of an empty result.
|
|
616
|
+
data = buildMechanicalDigest(records);
|
|
617
|
+
status = legOutcome.researchError ? "error" : "partial";
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
status = legOutcome.researchError
|
|
622
|
+
? "error"
|
|
623
|
+
: researchCleanFinish
|
|
624
|
+
? "completed"
|
|
625
|
+
: "partial";
|
|
626
|
+
}
|
|
627
|
+
const outcome = {
|
|
628
|
+
status,
|
|
629
|
+
...(data !== undefined && { data }),
|
|
630
|
+
...(researchContent && { content: researchContent }),
|
|
631
|
+
...(researchStop !== undefined && { stopReason: researchStop }),
|
|
632
|
+
// Terminal outcomes carry the WHOLE run's records (all legs) — the data
|
|
633
|
+
// above was built from all of them; in_progress legs carry per-leg
|
|
634
|
+
// records instead.
|
|
635
|
+
toolExecutions: records,
|
|
636
|
+
usage: { ...session.usage },
|
|
637
|
+
durationMs: Date.now() - startedAt,
|
|
638
|
+
...(extractionSource && { extractionSource }),
|
|
639
|
+
...(extractionError && { extractionError }),
|
|
640
|
+
...(legOutcome.wasteSignals.length > 0 && {
|
|
641
|
+
wasteSignals: legOutcome.wasteSignals,
|
|
642
|
+
}),
|
|
643
|
+
...(options.leg && { budget: resolveBudget(session) }),
|
|
644
|
+
};
|
|
645
|
+
emitEvent(options, {
|
|
646
|
+
type: status === "error" ? "error" : "complete",
|
|
647
|
+
runId,
|
|
648
|
+
agentId: definition.id,
|
|
649
|
+
timestamp: Date.now(),
|
|
650
|
+
status,
|
|
651
|
+
...(legOutcome.researchError && { error: legOutcome.researchError }),
|
|
652
|
+
});
|
|
653
|
+
return outcome;
|
|
654
|
+
}
|
|
655
|
+
async function disposeWorkerQuietly(worker) {
|
|
656
|
+
try {
|
|
657
|
+
await worker.dispose();
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
logger.debug("[IsolatedAgent] Worker dispose failed", {
|
|
661
|
+
error: error instanceof Error ? error.message : String(error),
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
function registerHandle(session) {
|
|
666
|
+
sessions.set(session.handle, session);
|
|
667
|
+
const ttlMs = session.options.handleTtlMs ?? DEFAULT_HANDLE_TTL_MS;
|
|
668
|
+
session.ttlTimer = setTimeout(() => {
|
|
669
|
+
void expireSession(session);
|
|
670
|
+
}, ttlMs);
|
|
671
|
+
// Never keep the process alive for an abandoned handle.
|
|
672
|
+
session.ttlTimer.unref?.();
|
|
673
|
+
}
|
|
674
|
+
async function expireSession(session) {
|
|
675
|
+
// The session may have been finalized/stopped between the timer firing
|
|
676
|
+
// and this callback running — never expire an entry we no longer own.
|
|
677
|
+
if (sessions.get(session.handle) !== session || session.tombstone) {
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
// In-flight guard: the TTL can fire in the race window before
|
|
681
|
+
// continueIsolatedAgent clears the timer. Never dispose a worker
|
|
682
|
+
// mid-generate — the running leg's own completion path re-registers the
|
|
683
|
+
// handle (fresh TTL) or finalizes and cleans up.
|
|
684
|
+
if (session.running) {
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
// TTL expiry: auto-dispose, keep the final outcome retrievable once —
|
|
688
|
+
// an abandoned leg is never silently lost. The tombstone is set BEFORE
|
|
689
|
+
// the async dispose so a concurrent continueAgent/stopAgent sees it and
|
|
690
|
+
// returns it instead of starting a leg on a worker being disposed.
|
|
691
|
+
const records = session.allRecords;
|
|
692
|
+
session.tombstone = {
|
|
693
|
+
status: records.length > 0 ? "partial" : "error",
|
|
694
|
+
data: buildMechanicalDigest(records),
|
|
695
|
+
stopReason: "handle-ttl-expired",
|
|
696
|
+
toolExecutions: records,
|
|
697
|
+
usage: { ...session.usage },
|
|
698
|
+
durationMs: session.spentMs,
|
|
699
|
+
...(session.options.leg && { budget: resolveBudget(session) }),
|
|
700
|
+
};
|
|
701
|
+
await disposeWorkerQuietly(session.worker);
|
|
702
|
+
logger.debug("[IsolatedAgent] Handle expired; outcome tombstoned", {
|
|
703
|
+
handle: session.handle,
|
|
704
|
+
agentId: session.definition.id,
|
|
705
|
+
});
|
|
706
|
+
// Second stage: evict the tombstone if nobody ever retrieves it, so an
|
|
707
|
+
// abandoned handle doesn't retain its records/history forever.
|
|
708
|
+
const graceTimer = setTimeout(() => {
|
|
709
|
+
if (sessions.get(session.handle) === session) {
|
|
710
|
+
sessions.delete(session.handle);
|
|
711
|
+
}
|
|
712
|
+
}, session.options.handleTtlMs ?? DEFAULT_HANDLE_TTL_MS);
|
|
713
|
+
graceTimer.unref?.();
|
|
714
|
+
}
|
|
715
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
716
|
+
/**
|
|
717
|
+
* Run an isolated sub-agent: research pass with tools under the turn budget,
|
|
718
|
+
* extraction pass tools-off on its own timeout, structured recovery,
|
|
719
|
+
* delivery guarantee, abort chaining, events, and (when `options.leg` is
|
|
720
|
+
* set) leashed legs with a TTL'd resume handle.
|
|
721
|
+
*/
|
|
722
|
+
export async function runIsolatedAgent(host, definition, input, options = {}) {
|
|
723
|
+
if (!definition?.id || !definition?.instructions) {
|
|
724
|
+
throw new Error("runIsolatedAgent: definition must include id and instructions");
|
|
725
|
+
}
|
|
726
|
+
const startedAt = Date.now();
|
|
727
|
+
const runId = randomUUID();
|
|
728
|
+
const leashed = !!options.leg;
|
|
729
|
+
const worker = host.createWorkerInstance({
|
|
730
|
+
logTag: `agent:${definition.id}`,
|
|
731
|
+
});
|
|
732
|
+
const session = {
|
|
733
|
+
handle: `agent-run-${runId}`,
|
|
734
|
+
runId,
|
|
735
|
+
host,
|
|
736
|
+
worker,
|
|
737
|
+
definition,
|
|
738
|
+
options,
|
|
739
|
+
callerSessionId: options.toolContext?.sessionId,
|
|
740
|
+
running: false,
|
|
741
|
+
history: [],
|
|
742
|
+
legIndex: 0,
|
|
743
|
+
spentMs: 0,
|
|
744
|
+
spentToolCalls: 0,
|
|
745
|
+
allRecords: [],
|
|
746
|
+
usage: { input: 0, output: 0, total: 0 },
|
|
747
|
+
seenCallHashes: new Map(),
|
|
748
|
+
seenResultHashes: new Set(),
|
|
749
|
+
};
|
|
750
|
+
// Tool context — set on the worker for EVERY tool call, including the
|
|
751
|
+
// caller-supplied sessionId (run id when none supplied).
|
|
752
|
+
worker.setToolContext({
|
|
753
|
+
...(options.toolContext ?? {}),
|
|
754
|
+
sessionId: options.toolContext?.sessionId ?? runId,
|
|
755
|
+
});
|
|
756
|
+
emitEvent(options, {
|
|
757
|
+
type: "start",
|
|
758
|
+
runId,
|
|
759
|
+
agentId: definition.id,
|
|
760
|
+
timestamp: startedAt,
|
|
761
|
+
});
|
|
762
|
+
emitEvent(options, {
|
|
763
|
+
type: "phase",
|
|
764
|
+
runId,
|
|
765
|
+
agentId: definition.id,
|
|
766
|
+
timestamp: Date.now(),
|
|
767
|
+
phase: "research",
|
|
768
|
+
});
|
|
769
|
+
const prompt = typeof input === "string" ? input : JSON.stringify(input, null, 2);
|
|
770
|
+
let keepAlive = false;
|
|
771
|
+
try {
|
|
772
|
+
const legOutcome = await runResearchLeg(session, prompt);
|
|
773
|
+
// Leashed mode: a leg the runner CUT SHORT (budget / step cap / waste)
|
|
774
|
+
// returns in_progress with a resume handle. A generate that returned
|
|
775
|
+
// normally — with or without a provider stopReason — is a finished turn
|
|
776
|
+
// and finalizes (see isLegCutShort).
|
|
777
|
+
if (leashed &&
|
|
778
|
+
!legOutcome.aborted &&
|
|
779
|
+
!legOutcome.researchError &&
|
|
780
|
+
isLegCutShort(legOutcome)) {
|
|
781
|
+
const nextPlan = await askNextPlan(session);
|
|
782
|
+
const legInfo = {
|
|
783
|
+
index: session.legIndex,
|
|
784
|
+
toolCalls: legOutcome.toolCalls,
|
|
785
|
+
durationMs: legOutcome.durationMs,
|
|
786
|
+
};
|
|
787
|
+
emitEvent(options, {
|
|
788
|
+
type: "leg_end",
|
|
789
|
+
runId,
|
|
790
|
+
agentId: definition.id,
|
|
791
|
+
timestamp: Date.now(),
|
|
792
|
+
legIndex: session.legIndex,
|
|
793
|
+
...(legOutcome.wasteSignals.length > 0 && {
|
|
794
|
+
wasteSignals: legOutcome.wasteSignals,
|
|
795
|
+
}),
|
|
796
|
+
});
|
|
797
|
+
session.legIndex++;
|
|
798
|
+
registerHandle(session);
|
|
799
|
+
keepAlive = true;
|
|
800
|
+
return {
|
|
801
|
+
status: "in_progress",
|
|
802
|
+
...(legOutcome.result?.content && {
|
|
803
|
+
content: legOutcome.result.content,
|
|
804
|
+
}),
|
|
805
|
+
...(legOutcome.result?.stopReason && {
|
|
806
|
+
stopReason: legOutcome.result.stopReason,
|
|
807
|
+
}),
|
|
808
|
+
toolExecutions: legOutcome.legRecords,
|
|
809
|
+
usage: { ...session.usage },
|
|
810
|
+
durationMs: Date.now() - startedAt,
|
|
811
|
+
handle: session.handle,
|
|
812
|
+
leg: legInfo,
|
|
813
|
+
delta: buildDelta(legOutcome.legRecords),
|
|
814
|
+
...(nextPlan && { nextPlan }),
|
|
815
|
+
...(legOutcome.wasteSignals.length > 0 && {
|
|
816
|
+
wasteSignals: legOutcome.wasteSignals,
|
|
817
|
+
}),
|
|
818
|
+
budget: resolveBudget(session),
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
return await finalizeOutcome(session, legOutcome, startedAt);
|
|
822
|
+
}
|
|
823
|
+
finally {
|
|
824
|
+
if (!keepAlive) {
|
|
825
|
+
await disposeWorkerQuietly(worker);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Resume a leashed run. `guidance`, when provided, is appended as a user
|
|
831
|
+
* turn before the next leg — this is how a supervisor re-steers a drifting
|
|
832
|
+
* worker. An expired handle returns its tombstoned final outcome once.
|
|
833
|
+
* Handles are owned by the host that created them; a handle with a leg
|
|
834
|
+
* already in flight refuses a second continuation instead of racing it.
|
|
835
|
+
*/
|
|
836
|
+
export async function continueIsolatedAgent(host, handle, guidance) {
|
|
837
|
+
const session = sessions.get(handle);
|
|
838
|
+
if (!session || session.host !== host) {
|
|
839
|
+
throw new Error(`Unknown agent handle "${handle}" — it may belong to another instance, or it expired and its final outcome was already retrieved.`);
|
|
840
|
+
}
|
|
841
|
+
if (session.tombstone) {
|
|
842
|
+
// Retrievable exactly once.
|
|
843
|
+
sessions.delete(handle);
|
|
844
|
+
return session.tombstone;
|
|
845
|
+
}
|
|
846
|
+
if (session.running) {
|
|
847
|
+
throw new Error(`Agent handle "${handle}" already has a leg in progress — await it before continuing.`);
|
|
848
|
+
}
|
|
849
|
+
session.running = true;
|
|
850
|
+
if (session.ttlTimer) {
|
|
851
|
+
clearTimeout(session.ttlTimer);
|
|
852
|
+
session.ttlTimer = undefined;
|
|
853
|
+
}
|
|
854
|
+
if (guidance) {
|
|
855
|
+
session.history.push(chatMessage("user", guidance));
|
|
856
|
+
}
|
|
857
|
+
const startedAt = Date.now();
|
|
858
|
+
let keepAlive = false;
|
|
859
|
+
try {
|
|
860
|
+
const legOutcome = await runResearchLeg(session, guidance
|
|
861
|
+
? "Follow the supervisor guidance above and continue the investigation."
|
|
862
|
+
: "Continue the investigation from where you left off.");
|
|
863
|
+
if (!legOutcome.aborted &&
|
|
864
|
+
!legOutcome.researchError &&
|
|
865
|
+
isLegCutShort(legOutcome)) {
|
|
866
|
+
const nextPlan = await askNextPlan(session);
|
|
867
|
+
const legInfo = {
|
|
868
|
+
index: session.legIndex,
|
|
869
|
+
toolCalls: legOutcome.toolCalls,
|
|
870
|
+
durationMs: legOutcome.durationMs,
|
|
871
|
+
};
|
|
872
|
+
emitEvent(session.options, {
|
|
873
|
+
type: "leg_end",
|
|
874
|
+
runId: session.runId,
|
|
875
|
+
agentId: session.definition.id,
|
|
876
|
+
timestamp: Date.now(),
|
|
877
|
+
legIndex: session.legIndex,
|
|
878
|
+
});
|
|
879
|
+
session.legIndex++;
|
|
880
|
+
registerHandle(session);
|
|
881
|
+
keepAlive = true;
|
|
882
|
+
return {
|
|
883
|
+
status: "in_progress",
|
|
884
|
+
...(legOutcome.result?.content && {
|
|
885
|
+
content: legOutcome.result.content,
|
|
886
|
+
}),
|
|
887
|
+
...(legOutcome.result?.stopReason && {
|
|
888
|
+
stopReason: legOutcome.result.stopReason,
|
|
889
|
+
}),
|
|
890
|
+
toolExecutions: legOutcome.legRecords,
|
|
891
|
+
usage: { ...session.usage },
|
|
892
|
+
durationMs: Date.now() - startedAt,
|
|
893
|
+
handle: session.handle,
|
|
894
|
+
leg: legInfo,
|
|
895
|
+
delta: buildDelta(legOutcome.legRecords),
|
|
896
|
+
...(nextPlan && { nextPlan }),
|
|
897
|
+
...(legOutcome.wasteSignals.length > 0 && {
|
|
898
|
+
wasteSignals: legOutcome.wasteSignals,
|
|
899
|
+
}),
|
|
900
|
+
budget: resolveBudget(session),
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
sessions.delete(handle);
|
|
904
|
+
return await finalizeOutcome(session, legOutcome, startedAt);
|
|
905
|
+
}
|
|
906
|
+
finally {
|
|
907
|
+
session.running = false;
|
|
908
|
+
if (!keepAlive) {
|
|
909
|
+
sessions.delete(handle);
|
|
910
|
+
await disposeWorkerQuietly(session.worker);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Stop a leashed run: dispose the worker and return the final outcome
|
|
916
|
+
* (mechanical digest over everything gathered so far). Refuses while a leg
|
|
917
|
+
* is in flight — cancel an in-flight leg via the run's `abortSignal` instead
|
|
918
|
+
* of disposing its worker mid-generate.
|
|
919
|
+
*/
|
|
920
|
+
export async function stopIsolatedAgent(host, handle) {
|
|
921
|
+
const session = sessions.get(handle);
|
|
922
|
+
if (!session || session.host !== host) {
|
|
923
|
+
throw new Error(`Unknown agent handle "${handle}" — it may belong to another instance, or it expired and its final outcome was already retrieved.`);
|
|
924
|
+
}
|
|
925
|
+
if (session.running) {
|
|
926
|
+
throw new Error(`Agent handle "${handle}" has a leg in progress — await it (or abort via the run's abortSignal) before stopping.`);
|
|
927
|
+
}
|
|
928
|
+
sessions.delete(handle);
|
|
929
|
+
if (session.tombstone) {
|
|
930
|
+
return session.tombstone;
|
|
931
|
+
}
|
|
932
|
+
if (session.ttlTimer) {
|
|
933
|
+
clearTimeout(session.ttlTimer);
|
|
934
|
+
}
|
|
935
|
+
await disposeWorkerQuietly(session.worker);
|
|
936
|
+
const records = session.allRecords;
|
|
937
|
+
return {
|
|
938
|
+
status: records.length > 0 ? "partial" : "error",
|
|
939
|
+
data: buildMechanicalDigest(records),
|
|
940
|
+
stopReason: "stopped",
|
|
941
|
+
toolExecutions: records,
|
|
942
|
+
usage: { ...session.usage },
|
|
943
|
+
durationMs: session.spentMs,
|
|
944
|
+
...(session.options.leg && { budget: resolveBudget(session) }),
|
|
945
|
+
};
|
|
946
|
+
}
|