@mlola/browser-core 0.1.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/LICENSE +21 -0
- package/README.md +10 -0
- package/dist/action-space.d.ts +36 -0
- package/dist/action-space.d.ts.map +1 -0
- package/dist/action-space.js +264 -0
- package/dist/action-space.js.map +1 -0
- package/dist/budget.d.ts +32 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +127 -0
- package/dist/budget.js.map +1 -0
- package/dist/decision-validation.d.ts +31 -0
- package/dist/decision-validation.d.ts.map +1 -0
- package/dist/decision-validation.js +114 -0
- package/dist/decision-validation.js.map +1 -0
- package/dist/executor.d.ts +37 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +209 -0
- package/dist/executor.js.map +1 -0
- package/dist/fallback.d.ts +19 -0
- package/dist/fallback.d.ts.map +1 -0
- package/dist/fallback.js +36 -0
- package/dist/fallback.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/orchestrator.d.ts +50 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +1168 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/state.d.ts +12 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +60 -0
- package/dist/state.js.map +1 -0
- package/dist/text.d.ts +34 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +137 -0
- package/dist/text.js.map +1 -0
- package/dist/verifier.d.ts +41 -0
- package/dist/verifier.d.ts.map +1 -0
- package/dist/verifier.js +237 -0
- package/dist/verifier.js.map +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task orchestrator (PRD §8.1).
|
|
3
|
+
*
|
|
4
|
+
* Owns the run lifecycle: observation → action space → decision → policy →
|
|
5
|
+
* execution → verification → recovery, plus planning, memory, budgets, human
|
|
6
|
+
* intervention, artifacts and the event log.
|
|
7
|
+
*
|
|
8
|
+
* The loop is deliberately boring: every step is observable, every effect is
|
|
9
|
+
* verified, and no model has direct browser authority.
|
|
10
|
+
*/
|
|
11
|
+
import { DEFAULT_BROWSER_CONFIG, DEFAULT_POLICY, RuntimeError, actionSignature, addFact, addFailedAttempt, asRuntimeError, createMemory, describeAction, failureRecord, looksSecretField, newRunId, newSessionId, newStepId, } from "@mlola/browser-protocol";
|
|
12
|
+
import { ApprovalBroker, PolicyEngine, assessRisk, isSafeNavigationUrl, mergePolicy, quarantineCandidates, } from "@mlola/browser-policy";
|
|
13
|
+
import { ArtifactRegistry } from "@mlola/browser-artifacts";
|
|
14
|
+
import { HumanHelpBroker, UnavailableHumanHelpHost } from "@mlola/browser-takeover";
|
|
15
|
+
import { EventLog, ensureRunDirs, resolveHome, writeRunMeta, writeRunSummary, } from "@mlola/browser-trace";
|
|
16
|
+
import { LoopDetector, nextStrategy } from "@mlola/browser-recovery";
|
|
17
|
+
import { buildActionSpace, spaceSummary } from "./action-space.js";
|
|
18
|
+
import { validateDecision } from "./decision-validation.js";
|
|
19
|
+
import { BudgetManager } from "./budget.js";
|
|
20
|
+
import { Executor } from "./executor.js";
|
|
21
|
+
import { TextUnavailableError, resolveText } from "./text.js";
|
|
22
|
+
import { inferDocumentTypeHint, verifyCompletion } from "./verifier.js";
|
|
23
|
+
import { looksComplex, summarizeSnapshot } from "./state.js";
|
|
24
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
|
+
export class TaskOrchestrator {
|
|
26
|
+
#opts;
|
|
27
|
+
#backend;
|
|
28
|
+
#events;
|
|
29
|
+
#artifacts;
|
|
30
|
+
#policy;
|
|
31
|
+
#budget;
|
|
32
|
+
#human;
|
|
33
|
+
#humanAvailable;
|
|
34
|
+
#approvals;
|
|
35
|
+
#executor;
|
|
36
|
+
#session;
|
|
37
|
+
#paths;
|
|
38
|
+
#runId = "";
|
|
39
|
+
#sessionId = "";
|
|
40
|
+
#status = "created";
|
|
41
|
+
#memory = createMemory("");
|
|
42
|
+
#history = [];
|
|
43
|
+
#subgoals = [];
|
|
44
|
+
#activeSubgoal = -1;
|
|
45
|
+
#planDisabled = false;
|
|
46
|
+
#loopDetector = new LoopDetector();
|
|
47
|
+
#forceFallback = false;
|
|
48
|
+
#paused = false;
|
|
49
|
+
#cancelled;
|
|
50
|
+
#uploadedArtifacts = new Set();
|
|
51
|
+
#recoveryAttemptsByStep = 0;
|
|
52
|
+
#stepNumber = 0;
|
|
53
|
+
#screenshotPolicy;
|
|
54
|
+
constructor(opts) {
|
|
55
|
+
this.#opts = opts;
|
|
56
|
+
this.#backend = opts.backend;
|
|
57
|
+
this.#screenshotPolicy = opts.screenshot ?? "off";
|
|
58
|
+
this.#policy = new PolicyEngine(mergePolicy(DEFAULT_POLICY, opts.policy));
|
|
59
|
+
this.#humanAvailable = opts.humanHelp !== undefined;
|
|
60
|
+
this.#approvals =
|
|
61
|
+
opts.approvals ??
|
|
62
|
+
new ApprovalBroker({
|
|
63
|
+
onRequest: (request) => this.#events?.emit("APPROVAL_REQUESTED", {
|
|
64
|
+
approvalId: request.id,
|
|
65
|
+
summary: request.summary,
|
|
66
|
+
risk: request.risk,
|
|
67
|
+
url: request.url,
|
|
68
|
+
}),
|
|
69
|
+
onResolved: (request, resolution) => this.#events?.emit("APPROVAL_RESOLVED", { approvalId: request.id, resolution }),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
get status() {
|
|
73
|
+
return this.#status;
|
|
74
|
+
}
|
|
75
|
+
get runId() {
|
|
76
|
+
return this.#runId;
|
|
77
|
+
}
|
|
78
|
+
get events() {
|
|
79
|
+
return this.#events?.events ?? [];
|
|
80
|
+
}
|
|
81
|
+
get artifacts() {
|
|
82
|
+
return this.#artifacts?.summaries() ?? [];
|
|
83
|
+
}
|
|
84
|
+
pause(reason) {
|
|
85
|
+
this.#paused = true;
|
|
86
|
+
this.#status = "paused";
|
|
87
|
+
this.#events?.emit("RUN_PAUSED", { ...(reason ? { reason } : {}) });
|
|
88
|
+
}
|
|
89
|
+
resume(reason) {
|
|
90
|
+
if (!this.#paused)
|
|
91
|
+
return;
|
|
92
|
+
this.#paused = false;
|
|
93
|
+
this.#status = "running";
|
|
94
|
+
this.#events?.emit("RUN_RESUMED", { ...(reason ? { reason } : {}) });
|
|
95
|
+
}
|
|
96
|
+
cancel(reason = "cancelled by user") {
|
|
97
|
+
this.#cancelled = reason;
|
|
98
|
+
}
|
|
99
|
+
/* ─────────────────────────────── main run ─────────────────────────────── */
|
|
100
|
+
async run(task) {
|
|
101
|
+
const startedAt = Date.now();
|
|
102
|
+
const runId = newRunId();
|
|
103
|
+
const sessionId = newSessionId();
|
|
104
|
+
this.#runId = runId;
|
|
105
|
+
this.#sessionId = sessionId;
|
|
106
|
+
this.#memory = createMemory(task.goal);
|
|
107
|
+
const home = this.#opts.home ?? resolveHome();
|
|
108
|
+
const paths = ensureRunDirs(runId, home);
|
|
109
|
+
this.#paths = paths;
|
|
110
|
+
const browser = { ...DEFAULT_BROWSER_CONFIG, ...(task.browser ?? {}) };
|
|
111
|
+
if (task.screenshot)
|
|
112
|
+
this.#screenshotPolicy = task.screenshot;
|
|
113
|
+
this.#events = new EventLog({
|
|
114
|
+
runId,
|
|
115
|
+
sessionId,
|
|
116
|
+
eventsPath: paths.eventsPath,
|
|
117
|
+
...(this.#opts.onEvent ? { onEvent: this.#opts.onEvent } : {}),
|
|
118
|
+
});
|
|
119
|
+
this.#artifacts = new ArtifactRegistry({ dir: paths.artifactsDir });
|
|
120
|
+
this.#budget = new BudgetManager({
|
|
121
|
+
...(task.budgets ? { budgets: task.budgets } : {}),
|
|
122
|
+
...(this.#opts.costRates ? { costRates: this.#opts.costRates } : {}),
|
|
123
|
+
...(this.#opts.now ? { now: this.#opts.now } : {}),
|
|
124
|
+
});
|
|
125
|
+
this.#human = new HumanHelpBroker({
|
|
126
|
+
host: this.#opts.humanHelp ?? new UnavailableHumanHelpHost(),
|
|
127
|
+
onRequest: (request) => this.#events?.emit("HUMAN_HELP_REQUESTED", {
|
|
128
|
+
helpId: request.id,
|
|
129
|
+
prompt: request.prompt,
|
|
130
|
+
kind: request.kind,
|
|
131
|
+
...(request.timeoutMs !== undefined ? { timeoutMs: request.timeoutMs } : {}),
|
|
132
|
+
}),
|
|
133
|
+
onResolved: (request, resolution) => this.#events?.emit("HUMAN_HELP_COMPLETED", {
|
|
134
|
+
helpId: request.id,
|
|
135
|
+
criteriaMet: resolution === "completed",
|
|
136
|
+
detail: resolution,
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
this.#human.setVerifier(async () => ({
|
|
140
|
+
snapshot: await this.#backend.observe(this.#sessionId),
|
|
141
|
+
artifacts: this.#artifacts?.summaries() ?? [],
|
|
142
|
+
events: this.#events?.events ?? [],
|
|
143
|
+
}));
|
|
144
|
+
writeRunMeta({
|
|
145
|
+
runId,
|
|
146
|
+
goal: task.goal,
|
|
147
|
+
...(task.startUrl ? { startUrl: task.startUrl } : {}),
|
|
148
|
+
backend: browser.backend,
|
|
149
|
+
startedAt,
|
|
150
|
+
version: "0.1.0",
|
|
151
|
+
}, home);
|
|
152
|
+
this.#events.emit("RUN_CREATED", {
|
|
153
|
+
goal: task.goal,
|
|
154
|
+
...(task.startUrl !== undefined ? { startUrl: task.startUrl } : {}),
|
|
155
|
+
backend: browser.backend,
|
|
156
|
+
});
|
|
157
|
+
let result;
|
|
158
|
+
try {
|
|
159
|
+
this.#session = await this.#backend.startSession({
|
|
160
|
+
runId,
|
|
161
|
+
sessionId,
|
|
162
|
+
browser,
|
|
163
|
+
artifactsDir: paths.artifactsDir,
|
|
164
|
+
screenshotsDir: paths.screenshotsDir,
|
|
165
|
+
});
|
|
166
|
+
this.#executor = new Executor({
|
|
167
|
+
backend: this.#backend,
|
|
168
|
+
events: this.#events,
|
|
169
|
+
artifacts: this.#artifacts,
|
|
170
|
+
sessionId,
|
|
171
|
+
runId,
|
|
172
|
+
});
|
|
173
|
+
this.#events.emit("SESSION_CREATED", { sessionId, backend: this.#session.backend });
|
|
174
|
+
this.#events.emit("SESSION_CONNECTED", {
|
|
175
|
+
sessionId,
|
|
176
|
+
...(this.#session.agentWindowId !== undefined ? { agentWindowId: this.#session.agentWindowId } : {}),
|
|
177
|
+
});
|
|
178
|
+
this.#events.emit("RUN_STARTED", { runId });
|
|
179
|
+
this.#status = "running";
|
|
180
|
+
result = await this.#mainLoop(task, startedAt);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
const runtimeError = asRuntimeError(err);
|
|
184
|
+
this.#events.emit("RUN_FAILED", { failure: failureRecord(runtimeError), status: this.#status });
|
|
185
|
+
result = this.#buildResult("failed", runtimeError.message, startedAt);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
result = await this.#cleanup(result);
|
|
189
|
+
}
|
|
190
|
+
writeRunSummary(runId, result, home);
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
/* ────────────────────────────── main loop ─────────────────────────────── */
|
|
194
|
+
async #mainLoop(task, startedAt) {
|
|
195
|
+
const events = this.#requireEvents();
|
|
196
|
+
const budget = this.#requireBudget();
|
|
197
|
+
let snapshot;
|
|
198
|
+
let failureNote;
|
|
199
|
+
// Initial navigation (runtime-owned, policy-checked).
|
|
200
|
+
if (task.startUrl) {
|
|
201
|
+
if (!isSafeNavigationUrl(task.startUrl)) {
|
|
202
|
+
throw new RuntimeError("POLICY_DENIED", `refusing to navigate to non-http(s) URL: ${task.startUrl}`);
|
|
203
|
+
}
|
|
204
|
+
const outcome = this.#policy.evaluate({
|
|
205
|
+
runId: this.#runId,
|
|
206
|
+
stepId: newStepId(),
|
|
207
|
+
operation: "OPEN_TAB",
|
|
208
|
+
risk: "navigate",
|
|
209
|
+
origin: originOf(task.startUrl),
|
|
210
|
+
url: task.startUrl,
|
|
211
|
+
});
|
|
212
|
+
if (outcome.mode === "deny") {
|
|
213
|
+
throw new RuntimeError("POLICY_DENIED", `navigation to ${task.startUrl} denied (${outcome.reason})`);
|
|
214
|
+
}
|
|
215
|
+
const nav = await this.#backend.navigate(this.#sessionId, task.startUrl);
|
|
216
|
+
events.emit("ACTION_EXECUTED", {
|
|
217
|
+
stepId: newStepId(),
|
|
218
|
+
action: `NAVIGATE ${task.startUrl}`,
|
|
219
|
+
latencyMs: nav.latencyMs,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
snapshot = await this.#observe();
|
|
223
|
+
if ((task.forcePlanner || looksComplex(task.goal)) && this.#opts.providers.planner) {
|
|
224
|
+
await this.#plan(task, "initial", snapshot);
|
|
225
|
+
}
|
|
226
|
+
while (true) {
|
|
227
|
+
if (this.#cancelled) {
|
|
228
|
+
events.emit("RUN_CANCELLED", { reason: this.#cancelled });
|
|
229
|
+
return this.#buildResult("cancelled", this.#cancelled, startedAt);
|
|
230
|
+
}
|
|
231
|
+
await this.#waitIfPaused();
|
|
232
|
+
if (this.#cancelled) {
|
|
233
|
+
events.emit("RUN_CANCELLED", { reason: this.#cancelled });
|
|
234
|
+
return this.#buildResult("cancelled", this.#cancelled, startedAt);
|
|
235
|
+
}
|
|
236
|
+
for (const warning of budget.warnings()) {
|
|
237
|
+
events.emit("BUDGET_WARNING", { reason: warning.reason, snapshot: numericSnapshot(budget) });
|
|
238
|
+
}
|
|
239
|
+
const verdict = budget.check();
|
|
240
|
+
if (verdict.kind === "fail") {
|
|
241
|
+
events.emit("BUDGET_EXHAUSTED", { reason: verdict.reason });
|
|
242
|
+
return this.#buildResult("failed", verdict.reason, startedAt);
|
|
243
|
+
}
|
|
244
|
+
if (verdict.kind === "degrade") {
|
|
245
|
+
this.#planDisabled = true;
|
|
246
|
+
events.emit("NOTE", { text: `degraded mode: ${verdict.reason}`, level: "warn" });
|
|
247
|
+
}
|
|
248
|
+
const outcome = await this.#runStep(task, snapshot, failureNote);
|
|
249
|
+
snapshot = outcome.pendingSnapshot ?? (await this.#observe());
|
|
250
|
+
if (outcome.kind === "complete") {
|
|
251
|
+
return this.#buildResult("completed", outcome.detail ?? "completed", startedAt, outcome.verification);
|
|
252
|
+
}
|
|
253
|
+
if (outcome.kind === "success" || outcome.kind === "no_effect") {
|
|
254
|
+
failureNote = undefined;
|
|
255
|
+
if (outcome.kind === "success")
|
|
256
|
+
this.#recoveryAttemptsByStep = 0;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
// fatal
|
|
260
|
+
const failure = outcome.failure ?? new RuntimeError("INTERNAL", outcome.detail ?? "fatal");
|
|
261
|
+
events.emit("RUN_FAILED", { failure: failureRecord(failure), status: "failed" });
|
|
262
|
+
return this.#buildResult("failed", failure.message, startedAt);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/* ─────────────────────────────── one step ─────────────────────────────── */
|
|
266
|
+
async #runStep(task, snapshotIn, failureNote) {
|
|
267
|
+
const events = this.#requireEvents();
|
|
268
|
+
const budget = this.#requireBudget();
|
|
269
|
+
const executor = this.#requireExecutor();
|
|
270
|
+
this.#stepNumber += 1;
|
|
271
|
+
const stepId = newStepId();
|
|
272
|
+
events.setStepId(stepId);
|
|
273
|
+
const snapshot = snapshotIn ?? (await this.#observe());
|
|
274
|
+
// 1. Action space (PRD §14) + injection quarantine (PRD §22).
|
|
275
|
+
const baseSpace = buildActionSpace({
|
|
276
|
+
snapshot,
|
|
277
|
+
uploadAllowed: this.#policy.modeForRisk("upload") !== "deny",
|
|
278
|
+
goal: task.goal,
|
|
279
|
+
});
|
|
280
|
+
const { space } = quarantineCandidates(baseSpace);
|
|
281
|
+
events.emit("ACTION_SPACE_BUILT", spaceSummary(space));
|
|
282
|
+
// 2. Decision (fast path with fallback).
|
|
283
|
+
let decision;
|
|
284
|
+
try {
|
|
285
|
+
decision = await this.#decide(task, snapshot, space, failureNote);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
const runtimeError = asRuntimeError(err);
|
|
289
|
+
return this.#recover(task, runtimeError, snapshot, stepId);
|
|
290
|
+
}
|
|
291
|
+
// 3. Terminal claims.
|
|
292
|
+
if (decision.operation === "DONE") {
|
|
293
|
+
return this.#handleDone(task, snapshot);
|
|
294
|
+
}
|
|
295
|
+
if (decision.operation === "BLOCKED" || decision.operation === "ASK_USER") {
|
|
296
|
+
return this.#handleHumanEscalation(task, snapshot, decision);
|
|
297
|
+
}
|
|
298
|
+
// 4. Resolve the target into a typed action.
|
|
299
|
+
let action;
|
|
300
|
+
try {
|
|
301
|
+
action = await this.#resolveAction(task, decision, space, snapshot);
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
const runtimeError = asRuntimeError(err);
|
|
305
|
+
if (runtimeError.code === "SECRET_TARGET" || runtimeError instanceof TextUnavailableError) {
|
|
306
|
+
return this.#handleHumanEscalation(task, snapshot, decision, runtimeError);
|
|
307
|
+
}
|
|
308
|
+
return this.#recover(task, runtimeError, snapshot, stepId);
|
|
309
|
+
}
|
|
310
|
+
// 5. Policy (PRD §20, §61).
|
|
311
|
+
const policyResult = await this.#applyPolicy(task, snapshot, action, decision, stepId);
|
|
312
|
+
if (policyResult.kind === "stop")
|
|
313
|
+
return policyResult.outcome;
|
|
314
|
+
// 6. Stop-before boundary (PRD §64.20).
|
|
315
|
+
const boundary = stopBeforeMatch(task.stopBefore, action, snapshot);
|
|
316
|
+
if (boundary) {
|
|
317
|
+
events.emit("NOTE", { text: `stop boundary reached: ${boundary}`, level: "info" });
|
|
318
|
+
return {
|
|
319
|
+
kind: "complete",
|
|
320
|
+
detail: `stopped at the requested boundary (${boundary})`,
|
|
321
|
+
pendingSnapshot: snapshot,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
// 7. Execute + verify.
|
|
325
|
+
const decided = { action, decision, snapshotId: snapshot.snapshotId, stepId };
|
|
326
|
+
const result = await executor.execute(decided, snapshot);
|
|
327
|
+
const signature = actionSignature(action);
|
|
328
|
+
const progressed = result.ok && result.verification.status === "verified" && !result.verification.noEffect;
|
|
329
|
+
const summary = {
|
|
330
|
+
step: this.#stepNumber,
|
|
331
|
+
action: describeAction(action),
|
|
332
|
+
outcome: result.ok ? (result.verification.noEffect ? "no_effect" : "ok") : "failed",
|
|
333
|
+
...(result.verification.evidence[0] ? { detail: result.verification.evidence[0].detail } : {}),
|
|
334
|
+
url: result.after.url,
|
|
335
|
+
};
|
|
336
|
+
this.#history.push(summary);
|
|
337
|
+
if (this.#history.length > 24)
|
|
338
|
+
this.#history.shift();
|
|
339
|
+
events.emit("STEP_COMPLETED", { step: this.#stepNumber, summary });
|
|
340
|
+
budget.stepCompleted();
|
|
341
|
+
this.#recordMemory(action, result, snapshot);
|
|
342
|
+
const loop = this.#loopDetector.record({
|
|
343
|
+
step: this.#stepNumber,
|
|
344
|
+
signature,
|
|
345
|
+
fingerprint: result.after.fingerprint,
|
|
346
|
+
progressed,
|
|
347
|
+
});
|
|
348
|
+
if (loop) {
|
|
349
|
+
events.emit("LOOP_DETECTED", {
|
|
350
|
+
kind: loop,
|
|
351
|
+
detail: `${signature} repeated without progress`,
|
|
352
|
+
step: this.#stepNumber,
|
|
353
|
+
});
|
|
354
|
+
return this.#recover(task, new RuntimeError("LOOP_DETECTED", `loop detected (${loop})`, { evidence: { signature } }), result.after, stepId);
|
|
355
|
+
}
|
|
356
|
+
if (!result.ok) {
|
|
357
|
+
return this.#recover(task, result.failure ?? new RuntimeError("EXECUTION_FAILED", "action failed"), result.after, stepId);
|
|
358
|
+
}
|
|
359
|
+
if (result.verification.noEffect) {
|
|
360
|
+
return { kind: "no_effect", pendingSnapshot: result.after, detail: summary.detail };
|
|
361
|
+
}
|
|
362
|
+
return { kind: "success", pendingSnapshot: result.after };
|
|
363
|
+
}
|
|
364
|
+
/* ────────────────────────────── decisions ─────────────────────────────── */
|
|
365
|
+
async #decide(task, snapshot, space, failureNote) {
|
|
366
|
+
const events = this.#requireEvents();
|
|
367
|
+
const budget = this.#requireBudget();
|
|
368
|
+
const providers = this.#opts.providers;
|
|
369
|
+
const subgoal = this.#currentSubgoal();
|
|
370
|
+
const input = {
|
|
371
|
+
runId: this.#runId,
|
|
372
|
+
step: this.#stepNumber,
|
|
373
|
+
goal: task.goal,
|
|
374
|
+
...(subgoal ? { subgoal: subgoal.description } : {}),
|
|
375
|
+
snapshot,
|
|
376
|
+
actionSpace: space,
|
|
377
|
+
memory: this.#memory,
|
|
378
|
+
artifacts: this.#artifacts?.summaries() ?? [],
|
|
379
|
+
history: this.#history,
|
|
380
|
+
...(failureNote !== undefined ? { failure: failureNote } : {}),
|
|
381
|
+
budget: budget.snapshot,
|
|
382
|
+
};
|
|
383
|
+
events.emit("DECISION_REQUESTED", {
|
|
384
|
+
step: this.#stepNumber,
|
|
385
|
+
provider: providers.decision.name,
|
|
386
|
+
operations: space.operations.length,
|
|
387
|
+
targets: Object.values(space.heads).reduce((sum, list) => sum + (list?.length ?? 0), 0),
|
|
388
|
+
});
|
|
389
|
+
const tryProvider = async (provider, isFallback) => {
|
|
390
|
+
try {
|
|
391
|
+
const output = await provider.decide(input);
|
|
392
|
+
const validation = validateDecision(output, space, this.#opts.validation);
|
|
393
|
+
for (const warning of validation.warnings) {
|
|
394
|
+
events.emit("NOTE", { text: `decision warning (${warning.code}): ${warning.detail}`, level: "warn" });
|
|
395
|
+
}
|
|
396
|
+
if (!validation.ok) {
|
|
397
|
+
events.emit("DECISION_INVALID", {
|
|
398
|
+
reason: validation.issues.map((i) => `${i.code}: ${i.detail}`).join("; "),
|
|
399
|
+
...(output.raw !== undefined ? { raw: output.raw } : {}),
|
|
400
|
+
});
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
budget.charge("decision", output.usage);
|
|
404
|
+
events.emit("DECISION_RECEIVED", {
|
|
405
|
+
decision: { ...output, raw: undefined },
|
|
406
|
+
...(output.usage !== undefined ? { usage: output.usage } : {}),
|
|
407
|
+
});
|
|
408
|
+
if (isFallback)
|
|
409
|
+
this.#forceFallback = false;
|
|
410
|
+
return output;
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
const runtimeError = asRuntimeError(err);
|
|
414
|
+
if (runtimeError.code === "SECRET_TARGET")
|
|
415
|
+
throw runtimeError;
|
|
416
|
+
events.emit("NOTE", {
|
|
417
|
+
text: `decision provider ${provider.name} failed: ${runtimeError.message}`,
|
|
418
|
+
level: "warn",
|
|
419
|
+
});
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
if (!this.#forceFallback) {
|
|
424
|
+
const fast = await tryProvider(providers.decision, false);
|
|
425
|
+
if (fast)
|
|
426
|
+
return fast;
|
|
427
|
+
}
|
|
428
|
+
const fallbackProvider = providers.fallbackDecision;
|
|
429
|
+
if (fallbackProvider && budget.fallbackAllowed() && !this.#planDisabled) {
|
|
430
|
+
events.emit("FALLBACK_STARTED", {
|
|
431
|
+
reason: this.#forceFallback ? "recovery escalated to fallback" : "fast path unavailable",
|
|
432
|
+
from: providers.decision.name,
|
|
433
|
+
to: fallbackProvider.name,
|
|
434
|
+
step: this.#stepNumber,
|
|
435
|
+
});
|
|
436
|
+
const escalated = await tryProvider(fallbackProvider, true);
|
|
437
|
+
events.emit("FALLBACK_COMPLETED", { provider: fallbackProvider.name, ok: escalated !== undefined });
|
|
438
|
+
if (escalated)
|
|
439
|
+
return escalated;
|
|
440
|
+
}
|
|
441
|
+
throw new RuntimeError("PROVIDER_FAILURE", `no valid decision from ${providers.decision.name}`);
|
|
442
|
+
}
|
|
443
|
+
/* ───────────────────────── target → typed action ──────────────────────── */
|
|
444
|
+
async #resolveAction(task, decision, space, snapshot) {
|
|
445
|
+
const head = space.headForOperation[decision.operation];
|
|
446
|
+
const refFor = (nodeIndex, nodeId) => {
|
|
447
|
+
const node = snapshot.nodes.find((n) => n.nodeId === nodeId);
|
|
448
|
+
return {
|
|
449
|
+
index: nodeIndex,
|
|
450
|
+
nodeId,
|
|
451
|
+
pageToken: snapshot.pageToken,
|
|
452
|
+
generation: snapshot.generation,
|
|
453
|
+
labelHash: node?.labelHash ?? "",
|
|
454
|
+
};
|
|
455
|
+
};
|
|
456
|
+
if (head !== undefined) {
|
|
457
|
+
const candidates = space.heads[head] ?? [];
|
|
458
|
+
const candidate = candidates.find((c) => c.key === decision.target);
|
|
459
|
+
if (!candidate) {
|
|
460
|
+
throw new RuntimeError("STALE_DECISION", `target ${decision.target} is no longer offered`);
|
|
461
|
+
}
|
|
462
|
+
switch (decision.operation) {
|
|
463
|
+
case "CLICK":
|
|
464
|
+
return { op: "CLICK", ref: refFor(candidate.nodeIndex, candidate.nodeId) };
|
|
465
|
+
case "TYPE_TEXT": {
|
|
466
|
+
const node = snapshot.nodes.find((n) => n.nodeId === candidate.nodeId);
|
|
467
|
+
const subgoal = this.#currentSubgoal();
|
|
468
|
+
const resolved = await resolveText({
|
|
469
|
+
goal: task.goal,
|
|
470
|
+
...(subgoal ? { subgoal: subgoal.description } : {}),
|
|
471
|
+
targetLabel: node?.label ?? candidate.label,
|
|
472
|
+
targetRole: node?.role ?? "textbox",
|
|
473
|
+
literals: [],
|
|
474
|
+
}, {
|
|
475
|
+
...(this.#opts.providers.text ? { provider: this.#opts.providers.text } : {}),
|
|
476
|
+
maxLength: this.#policy.config.maxTypedLength,
|
|
477
|
+
});
|
|
478
|
+
if (resolved.source === "model") {
|
|
479
|
+
this.#requireBudget().charge("text", { provider: resolved.provider, calls: 1 });
|
|
480
|
+
this.#events?.emit("PROVIDER_USAGE", {
|
|
481
|
+
role: "text",
|
|
482
|
+
usage: { provider: resolved.provider, calls: 1 },
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
const keyboardMode = node?.role === "combobox" || node?.role === "contenteditable";
|
|
486
|
+
return {
|
|
487
|
+
op: "TYPE_TEXT",
|
|
488
|
+
ref: refFor(candidate.nodeIndex, candidate.nodeId),
|
|
489
|
+
text: resolved.text,
|
|
490
|
+
mode: keyboardMode ? "keyboard" : "fill",
|
|
491
|
+
...(node?.role === "searchbox" ? { submitAfter: true } : {}),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
case "SELECT":
|
|
495
|
+
return {
|
|
496
|
+
op: "SELECT",
|
|
497
|
+
ref: refFor(candidate.nodeIndex, candidate.nodeId),
|
|
498
|
+
option: {
|
|
499
|
+
index: candidate.optionIndex,
|
|
500
|
+
label: optionLabelFromCandidate(candidate.label),
|
|
501
|
+
value: candidate.optionValue ?? "",
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
case "DOWNLOAD":
|
|
505
|
+
return { op: "DOWNLOAD", ref: refFor(candidate.nodeIndex, candidate.nodeId) };
|
|
506
|
+
case "UPLOAD": {
|
|
507
|
+
const record = this.#selectArtifact(task);
|
|
508
|
+
if (!record)
|
|
509
|
+
throw new RuntimeError("UPLOAD_FAILED", "no verified artifact is available to upload");
|
|
510
|
+
this.#uploadedArtifacts.add(record.id);
|
|
511
|
+
return {
|
|
512
|
+
op: "UPLOAD",
|
|
513
|
+
ref: refFor(candidate.nodeIndex, candidate.nodeId),
|
|
514
|
+
artifactId: record.id,
|
|
515
|
+
mechanism: "file_input",
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
case "OPEN_TAB": {
|
|
519
|
+
const url = candidate.url;
|
|
520
|
+
if (!url || !isSafeNavigationUrl(url)) {
|
|
521
|
+
throw new RuntimeError("POLICY_DENIED", "refusing to open a non-http(s) URL");
|
|
522
|
+
}
|
|
523
|
+
return { op: "OPEN_TAB", url, sourceRef: refFor(candidate.nodeIndex, candidate.nodeId) };
|
|
524
|
+
}
|
|
525
|
+
case "SWITCH_TAB": {
|
|
526
|
+
if (!candidate.tabId)
|
|
527
|
+
throw new RuntimeError("STALE_DECISION", "tab no longer exists");
|
|
528
|
+
return { op: "SWITCH_TAB", tabId: candidate.tabId };
|
|
529
|
+
}
|
|
530
|
+
default:
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
switch (decision.operation) {
|
|
535
|
+
case "SCROLL_UP":
|
|
536
|
+
return { op: "SCROLL_UP", amount: "half" };
|
|
537
|
+
case "SCROLL_DOWN":
|
|
538
|
+
return { op: "SCROLL_DOWN", amount: "half" };
|
|
539
|
+
case "WAIT":
|
|
540
|
+
return { op: "WAIT", ms: 600 };
|
|
541
|
+
case "BACK":
|
|
542
|
+
return { op: "BACK" };
|
|
543
|
+
case "FORWARD":
|
|
544
|
+
return { op: "FORWARD" };
|
|
545
|
+
case "ACCEPT_DIALOG":
|
|
546
|
+
return { op: "ACCEPT_DIALOG" };
|
|
547
|
+
case "DISMISS_DIALOG":
|
|
548
|
+
return { op: "DISMISS_DIALOG" };
|
|
549
|
+
default:
|
|
550
|
+
throw new RuntimeError("DECISION_INVALID", `cannot resolve operation ${decision.operation}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
#selectArtifact(task) {
|
|
554
|
+
const records = this.#artifacts?.records.filter((r) => r.verified && r.source !== "upload") ?? [];
|
|
555
|
+
if (records.length === 0)
|
|
556
|
+
return undefined;
|
|
557
|
+
const hint = inferDocumentTypeHint(task.goal);
|
|
558
|
+
const fresh = records.filter((r) => !this.#uploadedArtifacts.has(r.id));
|
|
559
|
+
const pool = fresh.length > 0 ? fresh : records;
|
|
560
|
+
const preferred = pool.filter((r) => hint === undefined || r.documentType === hint);
|
|
561
|
+
const finalPool = preferred.length > 0 ? preferred : pool;
|
|
562
|
+
return [...finalPool].sort((a, b) => b.createdAt - a.createdAt)[0];
|
|
563
|
+
}
|
|
564
|
+
/* ─────────────────────────────── policy ───────────────────────────────── */
|
|
565
|
+
async #applyPolicy(task, snapshot, action, decision, stepId) {
|
|
566
|
+
const events = this.#requireEvents();
|
|
567
|
+
const origin = originOf(snapshot.url);
|
|
568
|
+
const targetLabel = targetLabelOf(action, snapshot);
|
|
569
|
+
const ref = "ref" in action ? action.ref : undefined;
|
|
570
|
+
const node = ref ? snapshot.nodes.find((n) => n.nodeId === ref.nodeId) : undefined;
|
|
571
|
+
const secretsTouched = action.op === "TYPE_TEXT" && looksSecretField(`${targetLabel ?? ""} ${node?.kind ?? ""} ${node?.label ?? ""}`);
|
|
572
|
+
const assessment = assessRisk({
|
|
573
|
+
operation: action.op,
|
|
574
|
+
...(targetLabel !== undefined ? { targetLabel } : {}),
|
|
575
|
+
...(secretsTouched ? { secretsTouched } : {}),
|
|
576
|
+
});
|
|
577
|
+
const outcome = this.#policy.evaluate({
|
|
578
|
+
runId: this.#runId,
|
|
579
|
+
stepId,
|
|
580
|
+
operation: action.op,
|
|
581
|
+
risk: assessment.refined,
|
|
582
|
+
origin,
|
|
583
|
+
url: snapshot.url,
|
|
584
|
+
...(targetLabel !== undefined ? { targetLabel } : {}),
|
|
585
|
+
...(secretsTouched ? { secretsTouched } : {}),
|
|
586
|
+
});
|
|
587
|
+
const summary = `${describeAction(action)} (risk: ${outcome.risk})`;
|
|
588
|
+
if (outcome.mode === "deny") {
|
|
589
|
+
events.emit("POLICY_DENIED", { outcome, summary });
|
|
590
|
+
if (this.#humanAvailable) {
|
|
591
|
+
return {
|
|
592
|
+
kind: "stop",
|
|
593
|
+
outcome: await this.#handleHumanEscalation(task, snapshot, decision, new RuntimeError("POLICY_DENIED", `policy denied: ${outcome.reason}`)),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
return {
|
|
597
|
+
kind: "stop",
|
|
598
|
+
outcome: { kind: "fatal", failure: new RuntimeError("POLICY_DENIED", `policy denied: ${outcome.reason}`) },
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
if (outcome.mode === "allow") {
|
|
602
|
+
events.emit("POLICY_ALLOWED", { outcome, summary });
|
|
603
|
+
return { kind: "continue" };
|
|
604
|
+
}
|
|
605
|
+
if (outcome.mode === "ask") {
|
|
606
|
+
const resolution = await this.#approvals.request({
|
|
607
|
+
runId: this.#runId,
|
|
608
|
+
stepId,
|
|
609
|
+
summary,
|
|
610
|
+
risk: outcome.risk,
|
|
611
|
+
origin,
|
|
612
|
+
url: snapshot.url,
|
|
613
|
+
...(targetLabel !== undefined ? { targetLabel } : {}),
|
|
614
|
+
});
|
|
615
|
+
if (resolution === "approved") {
|
|
616
|
+
events.emit("POLICY_ALLOWED", { outcome: { ...outcome, mode: "allow", reason: "approved by user" }, summary });
|
|
617
|
+
return { kind: "continue" };
|
|
618
|
+
}
|
|
619
|
+
if (resolution === "takeover") {
|
|
620
|
+
return { kind: "stop", outcome: await this.#handleHumanEscalation(task, snapshot, decision) };
|
|
621
|
+
}
|
|
622
|
+
events.emit("POLICY_DENIED", { outcome: { ...outcome, mode: "deny", reason: "approval denied" }, summary });
|
|
623
|
+
return {
|
|
624
|
+
kind: "stop",
|
|
625
|
+
outcome: {
|
|
626
|
+
kind: this.#humanAvailable ? "no_effect" : "fatal",
|
|
627
|
+
...(this.#humanAvailable
|
|
628
|
+
? {}
|
|
629
|
+
: { failure: new RuntimeError("APPROVAL_DENIED", "the user denied this action") }),
|
|
630
|
+
detail: "approval denied",
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
// takeover mode (PRD §19, §20.2)
|
|
635
|
+
events.emit("TAKEOVER_STARTED", { reason: outcome.reason, prompt: summary });
|
|
636
|
+
const help = await this.#humanHelp(task, snapshot, {
|
|
637
|
+
kind: secretsTouched ? "credential" : "confirmation",
|
|
638
|
+
prompt: `${summary}\nReason: ${outcome.reason}\n` +
|
|
639
|
+
`Take over the browser to perform this step yourself, then hand control back.`,
|
|
640
|
+
completion: { criteria: { kind: "manual", description: "the human completed the step" } },
|
|
641
|
+
});
|
|
642
|
+
events.emit("TAKEOVER_ENDED", { resumedBy: help.resolution });
|
|
643
|
+
if (help.resolution === "cancelled") {
|
|
644
|
+
return {
|
|
645
|
+
kind: "stop",
|
|
646
|
+
outcome: { kind: "fatal", failure: new RuntimeError("HUMAN_HELP_TIMEOUT", "takeover was not completed") },
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
return { kind: "stop", outcome: { kind: "no_effect", detail: "human takeover" } };
|
|
650
|
+
}
|
|
651
|
+
/* ─────────────────────────── human intervention ───────────────────────── */
|
|
652
|
+
async #handleHumanEscalation(task, snapshot, decision, error) {
|
|
653
|
+
const budget = this.#requireBudget();
|
|
654
|
+
if (!this.#humanAvailable || budget.snapshot.humanHelpRequests >= budget.budgets.maxHumanHelpRequests) {
|
|
655
|
+
return {
|
|
656
|
+
kind: "fatal",
|
|
657
|
+
failure: error ?? new RuntimeError("BUDGET_EXHAUSTED", "human help is unavailable and no automatic path remains"),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
const kind = error?.code === "SECRET_TARGET"
|
|
661
|
+
? "credential"
|
|
662
|
+
: decision.operation === "ASK_USER"
|
|
663
|
+
? "interaction"
|
|
664
|
+
: "recovery";
|
|
665
|
+
const prompt = error?.code === "SECRET_TARGET"
|
|
666
|
+
? `This step needs a secret. Please enter it yourself, then hand control back.\n\nPage: ${snapshot.title} (${snapshot.url})`
|
|
667
|
+
: decision.operation === "ASK_USER"
|
|
668
|
+
? `The agent asked for help.\nGoal: ${task.goal}\nPage: ${snapshot.title} (${snapshot.url})`
|
|
669
|
+
: `The agent reported it is blocked.\nGoal: ${task.goal}\nPage: ${snapshot.title} (${snapshot.url})`;
|
|
670
|
+
const help = await this.#humanHelp(task, snapshot, {
|
|
671
|
+
kind,
|
|
672
|
+
prompt,
|
|
673
|
+
completion: { criteria: { kind: "manual", description: "the human resolved the blocker" } },
|
|
674
|
+
});
|
|
675
|
+
if (help.resolution === "cancelled") {
|
|
676
|
+
return {
|
|
677
|
+
kind: "fatal",
|
|
678
|
+
failure: error ?? new RuntimeError("HUMAN_HELP_TIMEOUT", "human help was not completed"),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
return { kind: "no_effect", detail: `human help: ${help.resolution}` };
|
|
682
|
+
}
|
|
683
|
+
#humanHelp(task, snapshot, input) {
|
|
684
|
+
const budget = this.#requireBudget();
|
|
685
|
+
budget.humanHelp();
|
|
686
|
+
this.#status = "human_control";
|
|
687
|
+
void task;
|
|
688
|
+
return this.#requireHuman()
|
|
689
|
+
.request({
|
|
690
|
+
runId: this.#runId,
|
|
691
|
+
sessionId: this.#sessionId,
|
|
692
|
+
kind: input.kind,
|
|
693
|
+
prompt: input.prompt,
|
|
694
|
+
...(input.completion !== undefined ? { completion: input.completion } : {}),
|
|
695
|
+
url: snapshot.url,
|
|
696
|
+
tabId: snapshot.activeTabId,
|
|
697
|
+
targets: snapshot.nodes
|
|
698
|
+
.filter((n) => n.flags.visible && n.operations.includes("TYPE_TEXT"))
|
|
699
|
+
.slice(0, 5)
|
|
700
|
+
.map((n) => ({
|
|
701
|
+
ref: {
|
|
702
|
+
index: n.index,
|
|
703
|
+
nodeId: n.nodeId,
|
|
704
|
+
pageToken: snapshot.pageToken,
|
|
705
|
+
generation: snapshot.generation,
|
|
706
|
+
labelHash: n.labelHash,
|
|
707
|
+
},
|
|
708
|
+
label: n.label,
|
|
709
|
+
role: n.role,
|
|
710
|
+
})),
|
|
711
|
+
})
|
|
712
|
+
.finally(() => {
|
|
713
|
+
this.#status = "running";
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
/* ───────────────────────────── completion ─────────────────────────────── */
|
|
717
|
+
async #handleDone(task, snapshot) {
|
|
718
|
+
const events = this.#requireEvents();
|
|
719
|
+
// With a plan, DONE means "current subgoal complete".
|
|
720
|
+
if (this.#subgoals.length > 0 && this.#activeSubgoal >= 0 && this.#activeSubgoal < this.#subgoals.length - 1) {
|
|
721
|
+
const current = this.#subgoals[this.#activeSubgoal];
|
|
722
|
+
current.status = "done";
|
|
723
|
+
events.emit("SUBGOAL_COMPLETED", { subgoal: current.description, index: this.#activeSubgoal });
|
|
724
|
+
this.#activeSubgoal += 1;
|
|
725
|
+
const next = this.#subgoals[this.#activeSubgoal];
|
|
726
|
+
next.status = "active";
|
|
727
|
+
events.emit("SUBGOAL_STARTED", { subgoal: next.description, index: this.#activeSubgoal });
|
|
728
|
+
addFact(this.#memory, `Subgoal completed: ${current.description}`, "system_runtime");
|
|
729
|
+
return { kind: "success", pendingSnapshot: snapshot, detail: "subgoal advanced", progressed: true };
|
|
730
|
+
}
|
|
731
|
+
const verification = verifyCompletion(task, {
|
|
732
|
+
snapshot,
|
|
733
|
+
artifacts: this.#artifacts?.summaries() ?? [],
|
|
734
|
+
events: events.events,
|
|
735
|
+
});
|
|
736
|
+
events.emit("VERIFICATION_RESULT", { verification });
|
|
737
|
+
if (verification.status === "verified") {
|
|
738
|
+
events.emit("RUN_COMPLETED", { result: { reason: "completion verified" }, status: "completed" });
|
|
739
|
+
this.#status = "completed";
|
|
740
|
+
return { kind: "complete", detail: "completion verified", verification, pendingSnapshot: snapshot };
|
|
741
|
+
}
|
|
742
|
+
if (verification.status === "unverified") {
|
|
743
|
+
// Unattended runs complete with an explicit "unverified" status rather
|
|
744
|
+
// than inventing confidence (PRD §51: never treat missing evidence as success).
|
|
745
|
+
if (!this.#humanAvailable) {
|
|
746
|
+
events.emit("RUN_COMPLETED", {
|
|
747
|
+
result: { reason: "completion accepted without verification (no oracle, no human)" },
|
|
748
|
+
status: "completed",
|
|
749
|
+
});
|
|
750
|
+
this.#status = "completed";
|
|
751
|
+
return {
|
|
752
|
+
kind: "complete",
|
|
753
|
+
detail: "completed without independent verification (no oracle available)",
|
|
754
|
+
verification,
|
|
755
|
+
pendingSnapshot: snapshot,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
const help = await this.#humanHelp(task, snapshot, {
|
|
759
|
+
kind: "verification",
|
|
760
|
+
prompt: `The agent believes the task is complete, but the runtime cannot verify it.\n` +
|
|
761
|
+
`Goal: ${task.goal}\nPage: ${snapshot.title} (${snapshot.url})\n` +
|
|
762
|
+
`Confirm whether the task is complete, then hand control back.`,
|
|
763
|
+
completion: { criteria: { kind: "manual", description: "human confirmed completion" } },
|
|
764
|
+
});
|
|
765
|
+
if (help.resolution === "completed" && help.criteriaMet) {
|
|
766
|
+
const humanVerification = {
|
|
767
|
+
status: "verified",
|
|
768
|
+
evidence: [{ kind: "human", ok: true, detail: "human confirmed completion", at: Date.now() }],
|
|
769
|
+
};
|
|
770
|
+
events.emit("VERIFICATION_RESULT", { verification: humanVerification });
|
|
771
|
+
events.emit("RUN_COMPLETED", { result: { reason: "human-confirmed completion" }, status: "completed" });
|
|
772
|
+
this.#status = "completed";
|
|
773
|
+
return {
|
|
774
|
+
kind: "complete",
|
|
775
|
+
detail: "human-confirmed completion",
|
|
776
|
+
verification: humanVerification,
|
|
777
|
+
pendingSnapshot: snapshot,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
addFact(this.#memory, "Unverified completion claim was not confirmed", "system_runtime");
|
|
781
|
+
return { kind: "no_effect", pendingSnapshot: snapshot, detail: "completion claim unverified" };
|
|
782
|
+
}
|
|
783
|
+
// Refuted: the model says done, the evidence says no.
|
|
784
|
+
addFact(this.#memory, "DONE claim refuted by runtime evidence", "system_runtime");
|
|
785
|
+
addFailedAttempt(this.#memory, "DONE claim refuted", this.#stepNumber);
|
|
786
|
+
events.emit("NOTE", { text: "completion claim refuted by the runtime oracle", level: "warn" });
|
|
787
|
+
return { kind: "no_effect", pendingSnapshot: snapshot, detail: "completion claim refuted" };
|
|
788
|
+
}
|
|
789
|
+
/* ───────────────────────────── recovery ───────────────────────────────── */
|
|
790
|
+
async #recover(task, failure, snapshot, stepId) {
|
|
791
|
+
const events = this.#requireEvents();
|
|
792
|
+
const budget = this.#requireBudget();
|
|
793
|
+
budget.recoveryAttempt();
|
|
794
|
+
this.#recoveryAttemptsByStep += 1;
|
|
795
|
+
this.#status = "recovering";
|
|
796
|
+
const decision = nextStrategy({
|
|
797
|
+
failure,
|
|
798
|
+
attempt: this.#recoveryAttemptsByStep,
|
|
799
|
+
maxAttemptsPerStep: budget.budgets.maxRecoveryAttemptsPerStep,
|
|
800
|
+
totalAttempts: budget.snapshot.recoveryAttempts,
|
|
801
|
+
maxAttemptsTotal: budget.budgets.maxRecoveryAttemptsTotal,
|
|
802
|
+
consecutiveNoProgress: this.#loopDetector.consecutiveNoProgress,
|
|
803
|
+
fallbackAvailable: budget.fallbackAllowed() && this.#opts.providers.fallbackDecision !== undefined,
|
|
804
|
+
humanAvailable: this.#humanAvailable && budget.snapshot.humanHelpRequests < budget.budgets.maxHumanHelpRequests,
|
|
805
|
+
actionRetrySafe: failure.code === "FILL_MISMATCH" || failure.code === "SELECT_MISMATCH" || failure.code === "DOWNLOAD_FAILED",
|
|
806
|
+
});
|
|
807
|
+
events.emit("RECOVERY_STARTED", {
|
|
808
|
+
failure: failureRecord(failure),
|
|
809
|
+
attempt: this.#recoveryAttemptsByStep,
|
|
810
|
+
strategy: decision.strategy.kind,
|
|
811
|
+
});
|
|
812
|
+
if (decision.escalate) {
|
|
813
|
+
events.emit("RECOVERY_ESCALATED", {
|
|
814
|
+
to: decision.strategy.kind,
|
|
815
|
+
reason: decision.strategy.kind === "fail" ? decision.strategy.reason : failure.message,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
const result = await this.#applyRecoveryStrategy(task, decision.strategy, snapshot, stepId);
|
|
819
|
+
events.emit("RECOVERY_COMPLETED", {
|
|
820
|
+
ok: result.kind === "success" || result.kind === "no_effect",
|
|
821
|
+
strategy: decision.strategy.kind,
|
|
822
|
+
...(result.detail !== undefined ? { detail: result.detail } : {}),
|
|
823
|
+
});
|
|
824
|
+
this.#status = "running";
|
|
825
|
+
return result;
|
|
826
|
+
}
|
|
827
|
+
async #applyRecoveryStrategy(task, strategy, snapshot, stepId) {
|
|
828
|
+
const events = this.#requireEvents();
|
|
829
|
+
switch (strategy.kind) {
|
|
830
|
+
case "reobserve":
|
|
831
|
+
case "retry_action":
|
|
832
|
+
return { kind: "no_effect", pendingSnapshot: await this.#observe(), detail: strategy.reason };
|
|
833
|
+
case "bounded_wait":
|
|
834
|
+
await sleep(strategy.ms);
|
|
835
|
+
return { kind: "no_effect", pendingSnapshot: await this.#observe(), detail: strategy.reason };
|
|
836
|
+
case "scroll_into_view":
|
|
837
|
+
case "dismiss_overlay": {
|
|
838
|
+
const target = this.#findOverlayDismiss(snapshot);
|
|
839
|
+
if (target) {
|
|
840
|
+
const action = {
|
|
841
|
+
op: "CLICK",
|
|
842
|
+
ref: {
|
|
843
|
+
index: target.index,
|
|
844
|
+
nodeId: target.nodeId,
|
|
845
|
+
pageToken: snapshot.pageToken,
|
|
846
|
+
generation: snapshot.generation,
|
|
847
|
+
labelHash: target.labelHash,
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
const outcome = await this.#requireExecutor().execute({ action, decision: syntheticDecision("CLICK", "recovery"), snapshotId: snapshot.snapshotId, stepId }, snapshot);
|
|
851
|
+
events.emit("DIALOG_HANDLED", { kind: "overlay", resolution: "clicked dismiss control" });
|
|
852
|
+
if (outcome.ok)
|
|
853
|
+
return { kind: "no_effect", pendingSnapshot: outcome.after, detail: "overlay dismissed" };
|
|
854
|
+
}
|
|
855
|
+
if (strategy.kind === "scroll_into_view") {
|
|
856
|
+
const action = { op: "SCROLL_DOWN", amount: "small" };
|
|
857
|
+
const outcome = await this.#requireExecutor().execute({ action, decision: syntheticDecision("SCROLL_DOWN", "recovery"), snapshotId: snapshot.snapshotId, stepId }, snapshot);
|
|
858
|
+
return { kind: "no_effect", pendingSnapshot: outcome.after, detail: strategy.reason };
|
|
859
|
+
}
|
|
860
|
+
return { kind: "no_effect", pendingSnapshot: await this.#observe(), detail: strategy.reason };
|
|
861
|
+
}
|
|
862
|
+
case "handle_dialog": {
|
|
863
|
+
const action = { op: "DISMISS_DIALOG" };
|
|
864
|
+
const outcome = await this.#requireExecutor().execute({ action, decision: syntheticDecision("DISMISS_DIALOG", "recovery"), snapshotId: snapshot.snapshotId, stepId }, snapshot);
|
|
865
|
+
events.emit("DIALOG_HANDLED", { kind: snapshot.blockingUi?.kind ?? "unknown", resolution: "dismiss" });
|
|
866
|
+
return { kind: "no_effect", pendingSnapshot: outcome.after, detail: "dialog dismissed" };
|
|
867
|
+
}
|
|
868
|
+
case "switch_tab": {
|
|
869
|
+
const target = snapshot.tabs.find((t) => !t.active);
|
|
870
|
+
if (target) {
|
|
871
|
+
const action = { op: "SWITCH_TAB", tabId: target.id };
|
|
872
|
+
const outcome = await this.#requireExecutor().execute({ action, decision: syntheticDecision("SWITCH_TAB", "recovery"), snapshotId: snapshot.snapshotId, stepId }, snapshot);
|
|
873
|
+
return { kind: "no_effect", pendingSnapshot: outcome.after, detail: `switched to ${target.id}` };
|
|
874
|
+
}
|
|
875
|
+
return { kind: "no_effect", pendingSnapshot: await this.#observe(), detail: "no other tab" };
|
|
876
|
+
}
|
|
877
|
+
case "fallback_provider":
|
|
878
|
+
this.#forceFallback = true;
|
|
879
|
+
return { kind: "no_effect", pendingSnapshot: await this.#observe(), detail: strategy.reason };
|
|
880
|
+
case "human": {
|
|
881
|
+
const help = await this.#humanHelp(task, snapshot, {
|
|
882
|
+
kind: "recovery",
|
|
883
|
+
prompt: `The runtime could not recover automatically: ${strategy.reason}\nGoal: ${task.goal}\nPage: ${snapshot.url}`,
|
|
884
|
+
completion: { criteria: { kind: "manual", description: "human resolved the failure" } },
|
|
885
|
+
});
|
|
886
|
+
if (help.resolution === "cancelled") {
|
|
887
|
+
return { kind: "fatal", failure: new RuntimeError("HUMAN_HELP_TIMEOUT", "human help was not completed") };
|
|
888
|
+
}
|
|
889
|
+
return { kind: "no_effect", pendingSnapshot: await this.#observe(), detail: "human assistance" };
|
|
890
|
+
}
|
|
891
|
+
case "fail":
|
|
892
|
+
return { kind: "fatal", failure: new RuntimeError(strategy.code, strategy.reason) };
|
|
893
|
+
default:
|
|
894
|
+
return { kind: "fatal", failure: new RuntimeError("INTERNAL", "unknown recovery strategy") };
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
#findOverlayDismiss(snapshot) {
|
|
898
|
+
const blocking = snapshot.blockingUi;
|
|
899
|
+
return snapshot.nodes.find((n) => n.operations.includes("CLICK") &&
|
|
900
|
+
n.flags.visible &&
|
|
901
|
+
(blocking?.nodeIndexes.includes(n.index) ||
|
|
902
|
+
/\b(accept|allow|agree|ok|got it|close|dismiss|no thanks|not now|terima|tutup)\b/i.test(n.label)));
|
|
903
|
+
}
|
|
904
|
+
/* ─────────────────────────────── planning ─────────────────────────────── */
|
|
905
|
+
async #plan(task, reason, snapshot) {
|
|
906
|
+
const planner = this.#opts.providers.planner;
|
|
907
|
+
if (!planner || this.#planDisabled)
|
|
908
|
+
return;
|
|
909
|
+
const events = this.#requireEvents();
|
|
910
|
+
const budget = this.#requireBudget();
|
|
911
|
+
if (budget.snapshot.calls.planner >= budget.budgets.maxPlannerCalls) {
|
|
912
|
+
this.#planDisabled = true;
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
events.emit("PLANNER_REQUESTED", { reason, step: this.#stepNumber });
|
|
916
|
+
try {
|
|
917
|
+
const output = await planner.plan({
|
|
918
|
+
runId: this.#runId,
|
|
919
|
+
goal: task.goal,
|
|
920
|
+
reason,
|
|
921
|
+
snapshotSummary: summarizeSnapshot(snapshot),
|
|
922
|
+
memory: this.#memory,
|
|
923
|
+
history: this.#history,
|
|
924
|
+
artifacts: this.#artifacts?.summaries() ?? [],
|
|
925
|
+
previousSubgoals: this.#subgoals,
|
|
926
|
+
budget: budget.snapshot,
|
|
927
|
+
});
|
|
928
|
+
budget.charge("planner", output.usage);
|
|
929
|
+
this.#subgoals = output.subgoals.map((s, i) => ({
|
|
930
|
+
id: s.id ?? `g${i + 1}`,
|
|
931
|
+
description: s.description,
|
|
932
|
+
...(s.hints ? { hints: s.hints } : {}),
|
|
933
|
+
status: i === 0 ? "active" : "pending",
|
|
934
|
+
}));
|
|
935
|
+
this.#activeSubgoal = 0;
|
|
936
|
+
events.emit("PLANNER_RECEIVED", {
|
|
937
|
+
plan: { ...output, usage: undefined },
|
|
938
|
+
subgoalCount: this.#subgoals.length,
|
|
939
|
+
});
|
|
940
|
+
const first = this.#subgoals[0];
|
|
941
|
+
if (first)
|
|
942
|
+
events.emit("SUBGOAL_STARTED", { subgoal: first.description, index: 0 });
|
|
943
|
+
}
|
|
944
|
+
catch (err) {
|
|
945
|
+
const runtimeError = asRuntimeError(err);
|
|
946
|
+
this.#planDisabled = true;
|
|
947
|
+
events.emit("NOTE", {
|
|
948
|
+
text: `planner failed, continuing without a plan: ${runtimeError.message}`,
|
|
949
|
+
level: "warn",
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
/* ─────────────────────────────── helpers ──────────────────────────────── */
|
|
954
|
+
async #observe() {
|
|
955
|
+
const started = Date.now();
|
|
956
|
+
const snapshot = await this.#backend.observe(this.#sessionId);
|
|
957
|
+
this.#events?.emit("SNAPSHOT_CAPTURED", {
|
|
958
|
+
snapshotId: snapshot.snapshotId,
|
|
959
|
+
generation: snapshot.generation,
|
|
960
|
+
url: snapshot.url,
|
|
961
|
+
title: snapshot.title,
|
|
962
|
+
nodeCount: snapshot.nodes.length,
|
|
963
|
+
fingerprint: snapshot.fingerprint,
|
|
964
|
+
latencyMs: Date.now() - started,
|
|
965
|
+
...(snapshot.blockingUi ? { blocking: snapshot.blockingUi.kind } : {}),
|
|
966
|
+
});
|
|
967
|
+
if (snapshot.injectedTexts.length > 0) {
|
|
968
|
+
this.#events?.emit("NOTE", {
|
|
969
|
+
text: `page contains ${snapshot.injectedTexts.length} instruction-like text fragment(s); treated as data`,
|
|
970
|
+
level: "warn",
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
if (snapshot.blockingUi && snapshot.blockingUi.nodeIndexes.length > 0) {
|
|
974
|
+
this.#events?.emit("BLOCKING_UI_DETECTED", {
|
|
975
|
+
kind: snapshot.blockingUi.kind,
|
|
976
|
+
...(snapshot.blockingUi.title !== undefined ? { title: snapshot.blockingUi.title } : {}),
|
|
977
|
+
...(snapshot.blockingUi.text !== undefined
|
|
978
|
+
? { text: snapshot.blockingUi.text.slice(0, 200) }
|
|
979
|
+
: {}),
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
return snapshot;
|
|
983
|
+
}
|
|
984
|
+
async #waitIfPaused() {
|
|
985
|
+
while (this.#paused && !this.#cancelled)
|
|
986
|
+
await sleep(200);
|
|
987
|
+
}
|
|
988
|
+
#currentSubgoal() {
|
|
989
|
+
return this.#activeSubgoal >= 0 ? this.#subgoals[this.#activeSubgoal] : undefined;
|
|
990
|
+
}
|
|
991
|
+
#recordMemory(action, result, before) {
|
|
992
|
+
if (result.artifact) {
|
|
993
|
+
const type = result.artifact.documentType ? ` (${result.artifact.documentType})` : "";
|
|
994
|
+
addFact(this.#memory, `Artifact ${result.artifact.id} ${result.artifact.name}${type} downloaded and verified`, "browser_runtime", result.artifact.id);
|
|
995
|
+
}
|
|
996
|
+
if (!result.ok) {
|
|
997
|
+
const detail = result.verification.evidence[0]?.detail ?? "failed";
|
|
998
|
+
addFailedAttempt(this.#memory, `${describeAction(action)} failed: ${detail}`, this.#stepNumber);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (action.op === "CLICK") {
|
|
1002
|
+
const node = before.nodes.find((n) => n.nodeId === action.ref.nodeId);
|
|
1003
|
+
if (node)
|
|
1004
|
+
addFact(this.#memory, `Clicked ${node.role} "${node.label}"`, "browser_runtime");
|
|
1005
|
+
}
|
|
1006
|
+
if (action.op === "SELECT") {
|
|
1007
|
+
addFact(this.#memory, `Selected "${action.option.label}"`, "browser_runtime");
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
async #cleanup(result) {
|
|
1011
|
+
const events = this.#events;
|
|
1012
|
+
try {
|
|
1013
|
+
if (this.#session) {
|
|
1014
|
+
if (this.#screenshotPolicy === "always" ||
|
|
1015
|
+
(this.#screenshotPolicy === "on-failure" && result.status !== "completed")) {
|
|
1016
|
+
try {
|
|
1017
|
+
const shot = await this.#backend.screenshot?.(this.#sessionId, `${result.status}-${result.reason}`);
|
|
1018
|
+
if (shot) {
|
|
1019
|
+
events?.emit("SCREENSHOT_CAPTURED", {
|
|
1020
|
+
path: shot.path,
|
|
1021
|
+
reason: `run ${result.status}`,
|
|
1022
|
+
...(shot.captureId !== undefined ? { captureId: shot.captureId } : {}),
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
catch (err) {
|
|
1027
|
+
events?.emit("NOTE", { text: `screenshot failed: ${String(err)}`, level: "warn" });
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
events?.emit("SESSION_STOPPING", { reason: result.reason });
|
|
1031
|
+
const report = await this.#backend.stopSession(this.#sessionId);
|
|
1032
|
+
events?.emit("SESSION_STOPPED", {
|
|
1033
|
+
cleanup: {
|
|
1034
|
+
closedTabs: report.closedTabs,
|
|
1035
|
+
returnedTabs: report.returnedTabs,
|
|
1036
|
+
orphanLocks: report.orphanLocks,
|
|
1037
|
+
errors: report.errors,
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
catch (err) {
|
|
1043
|
+
events?.emit("NOTE", { text: `cleanup error: ${String(err)}`, level: "error" });
|
|
1044
|
+
}
|
|
1045
|
+
finally {
|
|
1046
|
+
this.#approvals.cancelAll("denied");
|
|
1047
|
+
events?.setStepId(undefined);
|
|
1048
|
+
events?.close();
|
|
1049
|
+
}
|
|
1050
|
+
return { ...result, tracePath: this.#paths?.eventsPath };
|
|
1051
|
+
}
|
|
1052
|
+
#buildResult(status, reason, startedAt, verification) {
|
|
1053
|
+
const budget = this.#budget;
|
|
1054
|
+
return {
|
|
1055
|
+
runId: this.#runId,
|
|
1056
|
+
status,
|
|
1057
|
+
reason,
|
|
1058
|
+
steps: this.#stepNumber,
|
|
1059
|
+
...(verification ? { verification } : this.#verificationFromEvents()
|
|
1060
|
+
? { verification: this.#verificationFromEvents() }
|
|
1061
|
+
: {}),
|
|
1062
|
+
artifacts: this.#artifacts?.summaries() ?? [],
|
|
1063
|
+
durationMs: Date.now() - startedAt,
|
|
1064
|
+
usage: {
|
|
1065
|
+
decision: budget?.snapshot.calls.decision ?? 0,
|
|
1066
|
+
planner: budget?.snapshot.calls.planner ?? 0,
|
|
1067
|
+
text: budget?.snapshot.calls.text ?? 0,
|
|
1068
|
+
vision: budget?.snapshot.calls.vision ?? 0,
|
|
1069
|
+
costUsd: budget?.snapshot.costUsd ?? 0,
|
|
1070
|
+
},
|
|
1071
|
+
interventionCount: budget?.snapshot.humanHelpRequests ?? 0,
|
|
1072
|
+
recoveryCount: budget?.snapshot.recoveryAttempts ?? 0,
|
|
1073
|
+
notes: reason,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
#verificationFromEvents() {
|
|
1077
|
+
const events = this.#events?.events ?? [];
|
|
1078
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
1079
|
+
const event = events[i];
|
|
1080
|
+
if (event?.type === "VERIFICATION_RESULT")
|
|
1081
|
+
return event.payload.verification;
|
|
1082
|
+
}
|
|
1083
|
+
return undefined;
|
|
1084
|
+
}
|
|
1085
|
+
#requireEvents() {
|
|
1086
|
+
if (!this.#events)
|
|
1087
|
+
throw new Error("orchestrator is not running");
|
|
1088
|
+
return this.#events;
|
|
1089
|
+
}
|
|
1090
|
+
#requireBudget() {
|
|
1091
|
+
if (!this.#budget)
|
|
1092
|
+
throw new Error("orchestrator is not running");
|
|
1093
|
+
return this.#budget;
|
|
1094
|
+
}
|
|
1095
|
+
#requireExecutor() {
|
|
1096
|
+
if (!this.#executor)
|
|
1097
|
+
throw new Error("orchestrator is not running");
|
|
1098
|
+
return this.#executor;
|
|
1099
|
+
}
|
|
1100
|
+
#requireHuman() {
|
|
1101
|
+
if (!this.#human)
|
|
1102
|
+
throw new Error("orchestrator is not running");
|
|
1103
|
+
return this.#human;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
/* ─────────────────────────────── pure helpers ─────────────────────────────── */
|
|
1107
|
+
const originOf = (url) => {
|
|
1108
|
+
try {
|
|
1109
|
+
return new URL(url).origin;
|
|
1110
|
+
}
|
|
1111
|
+
catch {
|
|
1112
|
+
return "unknown";
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
const targetLabelOf = (action, snapshot) => {
|
|
1116
|
+
const ref = "ref" in action ? action.ref : undefined;
|
|
1117
|
+
if (ref) {
|
|
1118
|
+
const node = snapshot.nodes.find((n) => n.nodeId === ref.nodeId);
|
|
1119
|
+
if (node)
|
|
1120
|
+
return `${node.label} ${node.placeholder ?? ""}`.trim();
|
|
1121
|
+
}
|
|
1122
|
+
if (action.op === "OPEN_TAB")
|
|
1123
|
+
return action.url;
|
|
1124
|
+
return undefined;
|
|
1125
|
+
};
|
|
1126
|
+
const optionLabelFromCandidate = (label) => {
|
|
1127
|
+
const match = /→ option "([^"]*)"/.exec(label);
|
|
1128
|
+
return match?.[1] ?? label;
|
|
1129
|
+
};
|
|
1130
|
+
const syntheticDecision = (operation, reason) => ({
|
|
1131
|
+
operation,
|
|
1132
|
+
operationProbabilities: {},
|
|
1133
|
+
operationConfidence: 1,
|
|
1134
|
+
latencyMs: 0,
|
|
1135
|
+
provider: `runtime:${reason}`,
|
|
1136
|
+
});
|
|
1137
|
+
const stopBeforeMatch = (stopBefore, action, snapshot) => {
|
|
1138
|
+
if (!stopBefore || stopBefore.length === 0)
|
|
1139
|
+
return undefined;
|
|
1140
|
+
const ref = "ref" in action ? action.ref : undefined;
|
|
1141
|
+
const node = ref ? snapshot.nodes.find((n) => n.nodeId === ref.nodeId) : undefined;
|
|
1142
|
+
const haystack = [
|
|
1143
|
+
action.op,
|
|
1144
|
+
describeAction(action),
|
|
1145
|
+
node?.label ?? "",
|
|
1146
|
+
node?.placeholder ?? "",
|
|
1147
|
+
node?.value ?? "",
|
|
1148
|
+
action.op === "OPEN_TAB" ? action.url : "",
|
|
1149
|
+
]
|
|
1150
|
+
.join(" ")
|
|
1151
|
+
.toLowerCase();
|
|
1152
|
+
return stopBefore.find((pattern) => haystack.includes(pattern.toLowerCase()));
|
|
1153
|
+
};
|
|
1154
|
+
const numericSnapshot = (budget) => {
|
|
1155
|
+
const s = budget.snapshot;
|
|
1156
|
+
return {
|
|
1157
|
+
steps: s.steps,
|
|
1158
|
+
decisionCalls: s.calls.decision,
|
|
1159
|
+
plannerCalls: s.calls.planner,
|
|
1160
|
+
textCalls: s.calls.text,
|
|
1161
|
+
visionCalls: s.calls.vision,
|
|
1162
|
+
costUsd: s.costUsd,
|
|
1163
|
+
durationMs: s.durationMs,
|
|
1164
|
+
recoveryAttempts: s.recoveryAttempts,
|
|
1165
|
+
humanHelpRequests: s.humanHelpRequests,
|
|
1166
|
+
};
|
|
1167
|
+
};
|
|
1168
|
+
//# sourceMappingURL=orchestrator.js.map
|