@oh-my-pi/pi-coding-agent 16.2.11 → 16.2.12
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 +31 -0
- package/dist/cli.js +3005 -2848
- package/dist/types/cli/bench-cli.d.ts +0 -5
- package/dist/types/cli/dry-balance-cli.d.ts +0 -5
- package/dist/types/cli/models-cli.d.ts +1 -1
- package/dist/types/config/inline-tool-descriptors-mode.d.ts +1 -2
- package/dist/types/config/model-registry.d.ts +1 -23
- package/dist/types/config/model-resolver.d.ts +10 -15
- package/dist/types/config/models-config-schema.d.ts +0 -6
- package/dist/types/config/models-config.d.ts +0 -6
- package/dist/types/config/settings-schema.d.ts +31 -1
- package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +1 -0
- package/dist/types/extensibility/extensions/runner.d.ts +15 -0
- package/dist/types/lsp/client.d.ts +9 -3
- package/dist/types/modes/controllers/tool-args-reveal.d.ts +0 -5
- package/dist/types/task/spawn-policy.d.ts +17 -0
- package/dist/types/task/spawn-policy.test.d.ts +1 -0
- package/dist/types/task/types.d.ts +8 -2
- package/dist/types/tools/__tests__/eval-description.test.d.ts +1 -0
- package/dist/types/tools/browser/cmux/cmux-tab.d.ts +2 -2
- package/dist/types/tools/browser/registry.d.ts +2 -0
- package/dist/types/tools/browser/run-cancellation.d.ts +4 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +30 -1
- package/dist/types/tools/eval.d.ts +3 -6
- package/dist/types/tools/write.d.ts +1 -1
- package/dist/types/utils/image-vision-fallback.d.ts +1 -1
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +1 -1
- package/src/auto-thinking/classifier.ts +1 -1
- package/src/cli/bench-cli.ts +3 -12
- package/src/cli/dry-balance-cli.ts +1 -6
- package/src/cli/models-cli.ts +3 -81
- package/src/commands/models.ts +1 -2
- package/src/commit/model-selection.ts +4 -4
- package/src/config/inline-tool-descriptors-mode.ts +1 -2
- package/src/config/model-discovery.ts +32 -6
- package/src/config/model-registry.ts +11 -225
- package/src/config/model-resolver.ts +23 -135
- package/src/config/models-config-schema.ts +0 -22
- package/src/config/prompt-templates.ts +20 -0
- package/src/config/settings-schema.ts +35 -1
- package/src/config/settings.ts +60 -24
- package/src/eval/__tests__/agent-bridge.test.ts +17 -3
- package/src/eval/agent-bridge.ts +7 -9
- package/src/eval/completion-bridge.ts +1 -1
- package/src/eval/py/__tests__/runner-shell-output.test.ts +157 -0
- package/src/eval/py/runner.py +169 -18
- package/src/extensibility/extensions/model-api.ts +1 -3
- package/src/extensibility/extensions/runner.ts +62 -5
- package/src/lsp/client.ts +162 -45
- package/src/lsp/index.ts +31 -21
- package/src/main.ts +0 -1
- package/src/mcp/transports/http.ts +19 -17
- package/src/mcp/transports/stdio.test.ts +51 -1
- package/src/mcp/transports/stdio.ts +19 -9
- package/src/memories/index.ts +0 -1
- package/src/mnemopi/backend.ts +1 -1
- package/src/modes/components/model-selector.ts +32 -186
- package/src/modes/controllers/event-controller.ts +8 -39
- package/src/modes/controllers/selector-controller.ts +0 -1
- package/src/modes/controllers/tool-args-reveal.ts +0 -12
- package/src/prompts/system/subagent-system-prompt.md +2 -2
- package/src/prompts/system/tool-call-loop-redirect.md +8 -0
- package/src/prompts/tools/eval.md +2 -2
- package/src/prompts/tools/task.md +1 -1
- package/src/sdk.ts +2 -9
- package/src/session/agent-session.ts +89 -8
- package/src/session/session-context.ts +2 -1
- package/src/session/session-manager.ts +2 -1
- package/src/session/unexpected-stop-classifier.ts +1 -1
- package/src/task/index.ts +23 -27
- package/src/task/spawn-policy.test.ts +63 -0
- package/src/task/spawn-policy.ts +58 -0
- package/src/task/types.ts +77 -6
- package/src/tools/__tests__/eval-description.test.ts +19 -0
- package/src/tools/browser/cmux/cmux-tab.ts +66 -24
- package/src/tools/browser/registry.ts +25 -0
- package/src/tools/browser/run-cancellation.ts +47 -0
- package/src/tools/browser/tab-supervisor.ts +120 -7
- package/src/tools/browser/tab-worker.ts +22 -10
- package/src/tools/browser.ts +1 -0
- package/src/tools/eval.ts +15 -10
- package/src/tools/inspect-image.ts +1 -1
- package/src/tools/write.ts +49 -9
- package/src/utils/commit-message-generator.ts +0 -1
- package/src/utils/image-vision-fallback.ts +2 -3
- package/src/utils/title-generator.ts +1 -1
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { logger, Snowflake } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { logger, Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import { JsRuntime, type RuntimeHooks } from "../../../eval/js/shared/runtime";
|
|
6
6
|
import type { JsDisplayOutput } from "../../../eval/js/shared/types";
|
|
7
7
|
import { callSessionTool } from "../../../eval/js/tool-bridge";
|
|
8
|
-
import type { ToolSession } from "../../../sdk";
|
|
9
8
|
import { resizeImage } from "../../../utils/image-resize";
|
|
9
|
+
import type { ToolSession } from "../../index";
|
|
10
10
|
import { resolveToCwd } from "../../path-utils";
|
|
11
11
|
import { formatScreenshot } from "../../render-utils";
|
|
12
|
-
import { ToolAbortError, ToolError } from "../../tool-errors";
|
|
12
|
+
import { ToolAbortError, ToolError, throwIfAborted } from "../../tool-errors";
|
|
13
13
|
import { type AriaSnapshotOptions, buildAriaSnapshotScript } from "../aria/aria-snapshot";
|
|
14
14
|
import { DEFAULT_VIEWPORT } from "../launch";
|
|
15
15
|
import { extractReadableFromHtml, type ReadableFormat } from "../readable";
|
|
16
|
+
import { bindBrowserRunFacade, waitForBrowserRun } from "../run-cancellation";
|
|
16
17
|
import type { Observation, ReadyInfo, RunResultOk, ScreenshotResult, SessionSnapshot } from "../tab-protocol";
|
|
17
18
|
import {
|
|
18
19
|
type CmuxEvalResult,
|
|
@@ -44,7 +45,7 @@ interface RunContext {
|
|
|
44
45
|
session: SessionSnapshot;
|
|
45
46
|
displays: RunResultOk["displays"];
|
|
46
47
|
screenshots: ScreenshotResult[];
|
|
47
|
-
signal
|
|
48
|
+
signal: AbortSignal;
|
|
48
49
|
timeoutMs: number;
|
|
49
50
|
}
|
|
50
51
|
|
|
@@ -535,9 +536,10 @@ export class CmuxTab {
|
|
|
535
536
|
|
|
536
537
|
async waitForUrl(pattern: string | RegExp, opts?: { timeout?: number }): Promise<string> {
|
|
537
538
|
const timeoutMs = opts?.timeout ?? this.#runContext?.timeoutMs ?? 30_000;
|
|
539
|
+
const signal = this.#runContext?.signal;
|
|
538
540
|
if (typeof pattern === "string") {
|
|
539
|
-
await this.#request("browser.wait", { url_contains: pattern, timeout_ms: timeoutMs }, timeoutMs);
|
|
540
|
-
const result = (await this.#request("browser.url.get", {}, timeoutMs)) as CmuxUrlGetResult;
|
|
541
|
+
await this.#request("browser.wait", { url_contains: pattern, timeout_ms: timeoutMs }, timeoutMs, signal);
|
|
542
|
+
const result = (await this.#request("browser.url.get", {}, timeoutMs, signal)) as CmuxUrlGetResult;
|
|
541
543
|
if (typeof result.url === "string" && result.url.length > 0) {
|
|
542
544
|
this.#lastUrl = result.url;
|
|
543
545
|
}
|
|
@@ -545,29 +547,45 @@ export class CmuxTab {
|
|
|
545
547
|
}
|
|
546
548
|
const deadline = Date.now() + timeoutMs;
|
|
547
549
|
while (Date.now() <= deadline) {
|
|
548
|
-
const result = (await this.#request(
|
|
550
|
+
const result = (await this.#request(
|
|
551
|
+
"browser.url.get",
|
|
552
|
+
{},
|
|
553
|
+
Math.min(timeoutMs, 5_000),
|
|
554
|
+
signal,
|
|
555
|
+
)) as CmuxUrlGetResult;
|
|
549
556
|
if (typeof result.url === "string" && result.url.length > 0) {
|
|
550
557
|
this.#lastUrl = result.url;
|
|
551
558
|
if (pattern.test(result.url)) return result.url;
|
|
552
559
|
}
|
|
553
|
-
await Bun.sleep(200);
|
|
560
|
+
await untilAborted(signal, () => Bun.sleep(200));
|
|
554
561
|
}
|
|
555
562
|
throw new ToolError(`tab.waitForUrl() timed out after ${timeoutMs}ms`);
|
|
556
563
|
}
|
|
557
564
|
|
|
558
565
|
async waitForNavigation(opts?: { waitUntil?: WaitUntil; timeout?: number }): Promise<null> {
|
|
559
566
|
const timeoutMs = opts?.timeout ?? this.#runContext?.timeoutMs ?? 30_000;
|
|
567
|
+
const signal = this.#runContext?.signal;
|
|
560
568
|
// Cmux has no native "next navigation" wait — snapshot the current URL via a fresh
|
|
561
569
|
// `browser.url.get` (never the possibly-stale `#lastUrl`), then poll for a change
|
|
562
570
|
// from it (mirroring headless `page.waitForNavigation` intent) and optionally settle
|
|
563
571
|
// on the requested load state. Start it BEFORE the click/submit that navigates; after
|
|
564
572
|
// a completed nav it times out like puppeteer does.
|
|
565
|
-
const baseline = (await this.#request(
|
|
573
|
+
const baseline = (await this.#request(
|
|
574
|
+
"browser.url.get",
|
|
575
|
+
{},
|
|
576
|
+
Math.min(timeoutMs, 5_000),
|
|
577
|
+
signal,
|
|
578
|
+
)) as CmuxUrlGetResult;
|
|
566
579
|
const startUrl = typeof baseline.url === "string" && baseline.url.length > 0 ? baseline.url : this.#lastUrl;
|
|
567
580
|
if (typeof baseline.url === "string" && baseline.url.length > 0) this.#lastUrl = baseline.url;
|
|
568
581
|
const deadline = Date.now() + timeoutMs;
|
|
569
582
|
while (Date.now() <= deadline) {
|
|
570
|
-
const result = (await this.#request(
|
|
583
|
+
const result = (await this.#request(
|
|
584
|
+
"browser.url.get",
|
|
585
|
+
{},
|
|
586
|
+
Math.min(timeoutMs, 5_000),
|
|
587
|
+
signal,
|
|
588
|
+
)) as CmuxUrlGetResult;
|
|
571
589
|
if (typeof result.url === "string" && result.url.length > 0) {
|
|
572
590
|
this.#lastUrl = result.url;
|
|
573
591
|
if (result.url !== startUrl) {
|
|
@@ -576,12 +594,13 @@ export class CmuxTab {
|
|
|
576
594
|
"browser.wait",
|
|
577
595
|
{ load_state: mapWaitUntil(opts.waitUntil), timeout_ms: timeoutMs },
|
|
578
596
|
timeoutMs,
|
|
597
|
+
signal,
|
|
579
598
|
);
|
|
580
599
|
}
|
|
581
600
|
return null;
|
|
582
601
|
}
|
|
583
602
|
}
|
|
584
|
-
await Bun.sleep(200);
|
|
603
|
+
await untilAborted(signal, () => Bun.sleep(200));
|
|
585
604
|
}
|
|
586
605
|
throw new ToolError(`tab.waitForNavigation() timed out after ${timeoutMs}ms`);
|
|
587
606
|
}
|
|
@@ -627,6 +646,7 @@ export class CmuxTab {
|
|
|
627
646
|
opts?: { timeout?: number },
|
|
628
647
|
): Promise<CmuxResponse> {
|
|
629
648
|
const timeoutMs = opts?.timeout ?? this.#runContext?.timeoutMs ?? 30_000;
|
|
649
|
+
const signal = this.#runContext?.signal;
|
|
630
650
|
await this.#installResponseObserver();
|
|
631
651
|
const startId = await this.#responseCursor();
|
|
632
652
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -640,7 +660,7 @@ export class CmuxTab {
|
|
|
640
660
|
return response;
|
|
641
661
|
}
|
|
642
662
|
}
|
|
643
|
-
await Bun.sleep(100);
|
|
663
|
+
await untilAborted(signal, () => Bun.sleep(100));
|
|
644
664
|
}
|
|
645
665
|
throw new ToolError(`tab.waitForResponse() timed out after ${timeoutMs}ms`);
|
|
646
666
|
}
|
|
@@ -665,8 +685,14 @@ export class CmuxTab {
|
|
|
665
685
|
method: string,
|
|
666
686
|
params: Record<string, unknown>,
|
|
667
687
|
timeoutMs?: number,
|
|
688
|
+
signal: AbortSignal | undefined = this.#runContext?.signal,
|
|
668
689
|
): Promise<Record<string, unknown>> {
|
|
669
|
-
|
|
690
|
+
throwIfAborted(signal);
|
|
691
|
+
const result = await untilAborted(signal, () =>
|
|
692
|
+
this.#client.request(method, { surface_id: this.#surfaceId, ...params }, { timeoutMs }),
|
|
693
|
+
);
|
|
694
|
+
throwIfAborted(signal);
|
|
695
|
+
return result;
|
|
670
696
|
}
|
|
671
697
|
|
|
672
698
|
async #readGeometry(timeoutMs?: number): Promise<CmuxGeometry> {
|
|
@@ -717,12 +743,13 @@ export class CmuxTab {
|
|
|
717
743
|
...args: unknown[]
|
|
718
744
|
): Promise<unknown> {
|
|
719
745
|
const timeoutMs = opts?.timeout ?? this.#runContext?.timeoutMs ?? 30_000;
|
|
746
|
+
const signal = this.#runContext?.signal;
|
|
720
747
|
const pollingMs = typeof opts?.polling === "number" ? opts.polling : 200;
|
|
721
748
|
const deadline = Date.now() + timeoutMs;
|
|
722
749
|
while (Date.now() <= deadline) {
|
|
723
750
|
const value = typeof fn === "string" ? await this.#evalScript<unknown>(fn) : await this.evaluate(fn, ...args);
|
|
724
751
|
if (value) return value;
|
|
725
|
-
await Bun.sleep(pollingMs);
|
|
752
|
+
await untilAborted(signal, () => Bun.sleep(pollingMs));
|
|
726
753
|
}
|
|
727
754
|
throw new ToolError(`page.waitForFunction() timed out after ${timeoutMs}ms`);
|
|
728
755
|
}
|
|
@@ -863,16 +890,17 @@ export class CmuxTab {
|
|
|
863
890
|
}
|
|
864
891
|
|
|
865
892
|
async #waitForSelector(selector: string, timeoutMs: number): Promise<void> {
|
|
893
|
+
const signal = this.#runContext?.signal;
|
|
866
894
|
const spec = this.#selectorSpec(selector);
|
|
867
895
|
const nativeSelector = this.#nativeSelector(spec);
|
|
868
896
|
if (nativeSelector) {
|
|
869
|
-
await this.#request("browser.wait", { selector: nativeSelector, timeout_ms: timeoutMs }, timeoutMs);
|
|
897
|
+
await this.#request("browser.wait", { selector: nativeSelector, timeout_ms: timeoutMs }, timeoutMs, signal);
|
|
870
898
|
return;
|
|
871
899
|
}
|
|
872
900
|
const deadline = Date.now() + timeoutMs;
|
|
873
901
|
while (Date.now() <= deadline) {
|
|
874
902
|
if (await this.#selectorExists(spec)) return;
|
|
875
|
-
await Bun.sleep(100);
|
|
903
|
+
await untilAborted(signal, () => Bun.sleep(100));
|
|
876
904
|
}
|
|
877
905
|
throw new ToolError(`tab.waitFor(${JSON.stringify(selector)}) timed out after ${timeoutMs}ms`);
|
|
878
906
|
}
|
|
@@ -1251,22 +1279,26 @@ class CmuxBrowserFacade {
|
|
|
1251
1279
|
}
|
|
1252
1280
|
|
|
1253
1281
|
export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promise<RunResultOk> {
|
|
1282
|
+
const runAc = new AbortController();
|
|
1254
1283
|
const timeoutSignal = AbortSignal.timeout(opts.timeoutMs);
|
|
1255
|
-
const signal =
|
|
1284
|
+
const signal = AbortSignal.any(
|
|
1285
|
+
opts.signal ? [timeoutSignal, opts.signal, runAc.signal] : [timeoutSignal, runAc.signal],
|
|
1286
|
+
);
|
|
1256
1287
|
const displays: RunResultOk["displays"] = [];
|
|
1257
1288
|
const screenshots: ScreenshotResult[] = [];
|
|
1258
1289
|
const runId = crypto.randomUUID();
|
|
1259
1290
|
tab.setRunContext({ session: opts.snapshot, displays, screenshots, signal, timeoutMs: opts.timeoutMs });
|
|
1260
1291
|
const runtime = tab.ensureRuntime(opts.snapshot);
|
|
1261
1292
|
runtime.setCwd(opts.snapshot.cwd);
|
|
1293
|
+
const runTab = bindBrowserRunFacade(tab, signal);
|
|
1262
1294
|
runtime.setRunScope({
|
|
1263
|
-
page: tab.page,
|
|
1264
|
-
browser: tab.browser,
|
|
1265
|
-
tab,
|
|
1295
|
+
page: bindBrowserRunFacade(tab.page, signal),
|
|
1296
|
+
browser: bindBrowserRunFacade(tab.browser, signal),
|
|
1297
|
+
tab: runTab,
|
|
1266
1298
|
assert: (cond: unknown, text?: string): void => {
|
|
1267
1299
|
if (!cond) throw new ToolError(text ?? "Assertion failed");
|
|
1268
1300
|
},
|
|
1269
|
-
wait: (ms: number): Promise<void> =>
|
|
1301
|
+
wait: (ms: number): Promise<void> => waitForBrowserRun(ms, signal),
|
|
1270
1302
|
});
|
|
1271
1303
|
|
|
1272
1304
|
const { promise: cancelRejection, reject } = Promise.withResolvers<never>();
|
|
@@ -1282,9 +1314,18 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
|
|
|
1282
1314
|
|
|
1283
1315
|
try {
|
|
1284
1316
|
const hooks: RuntimeHooks = {
|
|
1285
|
-
onText: chunk =>
|
|
1286
|
-
|
|
1287
|
-
|
|
1317
|
+
onText: chunk => {
|
|
1318
|
+
throwIfAborted(signal);
|
|
1319
|
+
logger.debug(chunk.replace(/\n$/, ""));
|
|
1320
|
+
},
|
|
1321
|
+
onDisplay: output => {
|
|
1322
|
+
throwIfAborted(signal);
|
|
1323
|
+
pushDisplay(displays, output);
|
|
1324
|
+
},
|
|
1325
|
+
callTool: (name, args) => {
|
|
1326
|
+
throwIfAborted(signal);
|
|
1327
|
+
return callSessionTool(name, args, { session: opts.session, signal });
|
|
1328
|
+
},
|
|
1288
1329
|
};
|
|
1289
1330
|
// Like the inline worker fallback, cmux runs user JS in-process: awaited cmux/tool calls
|
|
1290
1331
|
// observe this abort signal, but a synchronous infinite loop cannot be interrupted here.
|
|
@@ -1295,6 +1336,7 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
|
|
|
1295
1336
|
return { displays, returnValue: cloneSafe(returnValue), screenshots };
|
|
1296
1337
|
} finally {
|
|
1297
1338
|
signal.removeEventListener("abort", onAbort);
|
|
1339
|
+
runAc.abort(new ToolAbortError("Browser run ended"));
|
|
1298
1340
|
tab.clearRunContext();
|
|
1299
1341
|
}
|
|
1300
1342
|
}
|
|
@@ -71,8 +71,28 @@ export async function acquireBrowser(kind: BrowserKind, opts: AcquireBrowserOpti
|
|
|
71
71
|
browsers.delete(key);
|
|
72
72
|
await disposeBrowserHandle(existing, { kill: false });
|
|
73
73
|
}
|
|
74
|
+
// Short-circuit before launching: the tool wrapper's `untilAborted` only
|
|
75
|
+
// rejects its outer promise on abort; without this check `openBrowserHandle`
|
|
76
|
+
// would still fire and its result would land in `browsers` below.
|
|
77
|
+
if (opts.signal?.aborted) throw new ToolAbortError("Browser open aborted");
|
|
74
78
|
|
|
75
79
|
const handle = await openBrowserHandle(kind, opts);
|
|
80
|
+
// The launch may resolve AFTER the caller has already aborted (the outer
|
|
81
|
+
// `untilAborted` rejects immediately on abort but does not cancel the
|
|
82
|
+
// inner promise, and `launchHeadlessBrowser` does not accept a signal).
|
|
83
|
+
// Without this branch the completed handle sits in `browsers` at
|
|
84
|
+
// refCount:0 forever — no tab ever takes a hold, `releaseBrowser` never
|
|
85
|
+
// fires, and `releaseAllTabs` walks `tabs`, not `browsers`, so the
|
|
86
|
+
// orphaned Chromium/app process / puppeteer handle survives to process
|
|
87
|
+
// exit. (Issue #3963.)
|
|
88
|
+
if (opts.signal?.aborted) {
|
|
89
|
+
await disposeBrowserHandle(handle, { kill: kind.kind === "spawned" }).catch(err => {
|
|
90
|
+
logger.debug("Failed to dispose orphan browser after abort", {
|
|
91
|
+
error: err instanceof Error ? err.message : String(err),
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
throw new ToolAbortError("Browser open aborted");
|
|
95
|
+
}
|
|
76
96
|
browsers.set(key, handle);
|
|
77
97
|
return handle;
|
|
78
98
|
}
|
|
@@ -239,3 +259,8 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean
|
|
|
239
259
|
}
|
|
240
260
|
if (opts.kill && handle.pid !== undefined) await gracefulKillTreeOnce(handle.pid);
|
|
241
261
|
}
|
|
262
|
+
|
|
263
|
+
/** Test-only accessor for the module-global browsers map. */
|
|
264
|
+
export function getBrowsersMapForTest(): ReadonlyMap<string, BrowserHandle> {
|
|
265
|
+
return browsers;
|
|
266
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { untilAborted } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import { throwIfAborted } from "../tool-errors";
|
|
3
|
+
|
|
4
|
+
/** Sleeps inside evaluated browser code while honoring the owning run's cancellation signal. */
|
|
5
|
+
export async function waitForBrowserRun(ms: number, signal: AbortSignal): Promise<void> {
|
|
6
|
+
throwIfAborted(signal);
|
|
7
|
+
await untilAborted(signal, () => Bun.sleep(ms));
|
|
8
|
+
throwIfAborted(signal);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Binds a long-lived browser facade to one evaluated run's abort signal. */
|
|
12
|
+
export function bindBrowserRunFacade<T extends object>(target: T, signal: AbortSignal): T {
|
|
13
|
+
const cache = new Map<PropertyKey, unknown>();
|
|
14
|
+
return new Proxy(target, {
|
|
15
|
+
get(current, prop) {
|
|
16
|
+
throwIfAborted(signal);
|
|
17
|
+
const cached = cache.get(prop);
|
|
18
|
+
if (cached) return cached;
|
|
19
|
+
const value = Reflect.get(current, prop, current);
|
|
20
|
+
if (typeof value === "function") {
|
|
21
|
+
const wrapped = (...args: unknown[]): unknown => {
|
|
22
|
+
throwIfAborted(signal);
|
|
23
|
+
const result = Reflect.apply(value, current, args);
|
|
24
|
+
if (result && typeof result === "object") {
|
|
25
|
+
const then = Reflect.get(result, "then");
|
|
26
|
+
if (typeof then === "function") {
|
|
27
|
+
return Promise.resolve(result).then(resolved => {
|
|
28
|
+
throwIfAborted(signal);
|
|
29
|
+
return resolved;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
throwIfAborted(signal);
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
cache.set(prop, wrapped);
|
|
37
|
+
return wrapped;
|
|
38
|
+
}
|
|
39
|
+
if (value && typeof value === "object") {
|
|
40
|
+
const wrapped = bindBrowserRunFacade(value, signal);
|
|
41
|
+
cache.set(prop, wrapped);
|
|
42
|
+
return wrapped;
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { getPuppeteerDir, logger, Snowflake, workerHostEntry } from "@oh-my-pi/pi-utils";
|
|
2
2
|
import type { Page, Target } from "puppeteer-core";
|
|
3
3
|
import { callSessionTool } from "../../eval/js/tool-bridge";
|
|
4
|
-
import type { ToolSession } from "../../sdk";
|
|
5
4
|
import { webpExclusionForModel } from "../../utils/image-loading";
|
|
5
|
+
import type { ToolSession } from "../index";
|
|
6
6
|
import { expandPath } from "../path-utils";
|
|
7
7
|
import { ToolAbortError, ToolError } from "../tool-errors";
|
|
8
8
|
import { pickElectronTarget } from "./attach";
|
|
@@ -59,6 +59,13 @@ interface TabSessionBase<TBrowser extends BrowserHandle = BrowserHandle> {
|
|
|
59
59
|
pending: Map<string, PendingRun>;
|
|
60
60
|
dialogPolicy?: DialogPolicy;
|
|
61
61
|
kindTag: BrowserKindTag;
|
|
62
|
+
/**
|
|
63
|
+
* Session id of the caller that CREATED the tab. Preserved across reuse so
|
|
64
|
+
* that dispose of the creating session can reap browser resources without
|
|
65
|
+
* yanking the tab out from under a subagent that only reused it.
|
|
66
|
+
* Undefined when the acquirer did not identify itself.
|
|
67
|
+
*/
|
|
68
|
+
ownerSessionId?: string;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
export interface WorkerTabSession extends TabSessionBase<PuppeteerBrowserHandle> {
|
|
@@ -84,6 +91,12 @@ export interface AcquireTabOptions {
|
|
|
84
91
|
timeoutMs: number;
|
|
85
92
|
dialogs?: DialogPolicy;
|
|
86
93
|
cmuxSurface?: string;
|
|
94
|
+
/**
|
|
95
|
+
* Session id of the acquirer. Recorded on the tab when created (never on
|
|
96
|
+
* reuse) so `releaseTabsForOwner` can walk the shared tabs map on session
|
|
97
|
+
* dispose. Optional — omitting it opts the tab out of session-scoped reap.
|
|
98
|
+
*/
|
|
99
|
+
ownerSessionId?: string;
|
|
87
100
|
}
|
|
88
101
|
|
|
89
102
|
export interface AcquireTabResult {
|
|
@@ -239,6 +252,16 @@ async function acquireTabImpl(
|
|
|
239
252
|
}
|
|
240
253
|
}
|
|
241
254
|
|
|
255
|
+
// If the caller aborted while we were spawning/initializing the worker,
|
|
256
|
+
// tear the freshly-built worker down before publishing the tab so the
|
|
257
|
+
// browser refCount (which `holdBrowser` below would take) never grows for
|
|
258
|
+
// a tab nobody is waiting for.
|
|
259
|
+
if (opts.signal?.aborted) {
|
|
260
|
+
await worker.terminate().catch(() => undefined);
|
|
261
|
+
if (tempHold) await releaseBrowser(browser, { kill: false }).catch(() => undefined);
|
|
262
|
+
throw new ToolAbortError("Browser tab open aborted");
|
|
263
|
+
}
|
|
264
|
+
|
|
242
265
|
holdBrowser(browser);
|
|
243
266
|
if (tempHold) await releaseBrowser(browser, { kill: false });
|
|
244
267
|
const tab: WorkerTabSession = {
|
|
@@ -252,6 +275,7 @@ async function acquireTabImpl(
|
|
|
252
275
|
pending: new Map(),
|
|
253
276
|
dialogPolicy: opts.dialogs,
|
|
254
277
|
kindTag: browser.kind.kind,
|
|
278
|
+
ownerSessionId: opts.ownerSessionId,
|
|
255
279
|
};
|
|
256
280
|
worker.onMessage(msg => handleTabMessage(tab, msg));
|
|
257
281
|
tabs.set(name, tab);
|
|
@@ -303,6 +327,11 @@ async function acquireCmuxTab(
|
|
|
303
327
|
await cmuxTab.goto(opts.url, { waitUntil: opts.waitUntil ?? "load", timeoutMs: opts.timeoutMs });
|
|
304
328
|
}
|
|
305
329
|
const info = await cmuxTab.readyInfo(opts.viewport ?? DEFAULT_VIEWPORT);
|
|
330
|
+
// If the caller aborted while we were opening the cmux surface, close the
|
|
331
|
+
// surface (if we own it) instead of taking a browser hold on it.
|
|
332
|
+
if (opts.signal?.aborted) {
|
|
333
|
+
throw new ToolAbortError("Browser tab open aborted");
|
|
334
|
+
}
|
|
306
335
|
holdBrowser(browser);
|
|
307
336
|
const tab: CmuxTabSession = {
|
|
308
337
|
name,
|
|
@@ -317,6 +346,7 @@ async function acquireCmuxTab(
|
|
|
317
346
|
dialogPolicy: opts.dialogs,
|
|
318
347
|
kindTag: browser.kind.kind,
|
|
319
348
|
cmuxAttachedSurface: attachedSurface,
|
|
349
|
+
ownerSessionId: opts.ownerSessionId,
|
|
320
350
|
};
|
|
321
351
|
tabs.set(name, tab);
|
|
322
352
|
return { tab, created: true };
|
|
@@ -386,12 +416,28 @@ async function runInTabWithSnapshot(
|
|
|
386
416
|
timeoutMs: opts.timeoutMs,
|
|
387
417
|
session: snapshot,
|
|
388
418
|
});
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
419
|
+
try {
|
|
420
|
+
return await raceWithTimeout(
|
|
421
|
+
promise,
|
|
422
|
+
opts.timeoutMs + GRACE_MS,
|
|
423
|
+
"Browser code execution hung past grace; tab killed",
|
|
424
|
+
async reason => await forceKillTab(name, reason),
|
|
425
|
+
);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error instanceof ToolError && error.message.startsWith("Browser code execution timed out after ")) {
|
|
428
|
+
try {
|
|
429
|
+
if (tab.worker.mode === "inline")
|
|
430
|
+
await forceKillTab(name, "Browser code execution timed out; tab killed");
|
|
431
|
+
else await recycleTimedOutWorkerTab(tab, opts.timeoutMs + GRACE_MS);
|
|
432
|
+
} catch (recycleError) {
|
|
433
|
+
logger.warn("Failed to recycle timed-out browser tab worker; killing tab", {
|
|
434
|
+
error: recycleError instanceof Error ? recycleError.message : String(recycleError),
|
|
435
|
+
});
|
|
436
|
+
await forceKillTab(name, "Browser code execution timed out; tab killed");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
395
441
|
} finally {
|
|
396
442
|
opts.signal?.removeEventListener("abort", abort);
|
|
397
443
|
tab.pending.delete(id);
|
|
@@ -467,6 +513,34 @@ export async function dropHeadlessTabs(): Promise<void> {
|
|
|
467
513
|
for (const name of names) await releaseTab(name);
|
|
468
514
|
}
|
|
469
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Release every tab created by the given session id. Invoked from
|
|
518
|
+
* `AgentSession.dispose()` so headless/spawned Chromium and workers the
|
|
519
|
+
* session opened do not leak into the long-lived process — the module-global
|
|
520
|
+
* `tabs`/`browsers` maps that back this tool are not otherwise walked by
|
|
521
|
+
* session teardown. (Issue #3963.)
|
|
522
|
+
*
|
|
523
|
+
* Ownership is recorded ONLY on tab creation (`acquireTab` with
|
|
524
|
+
* `ownerSessionId`), never on reuse: a subagent re-driving a tab another
|
|
525
|
+
* session opened will not yank teardown responsibility away from the
|
|
526
|
+
* creator. Tabs opened with no owner (e.g. from an SDK caller that doesn't
|
|
527
|
+
* identify a session) are skipped and must be released explicitly.
|
|
528
|
+
*/
|
|
529
|
+
export async function releaseTabsForOwner(ownerId: string, opts: ReleaseTabOptions = {}): Promise<number> {
|
|
530
|
+
if (!ownerId) return 0;
|
|
531
|
+
const names = [...tabs.values()].filter(tab => tab.ownerSessionId === ownerId).map(tab => tab.name);
|
|
532
|
+
let count = 0;
|
|
533
|
+
for (const name of names) {
|
|
534
|
+
if (await releaseTab(name, opts)) count++;
|
|
535
|
+
}
|
|
536
|
+
return count;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Test-only accessor for the module-global tabs map. */
|
|
540
|
+
export function getTabsMapForTest(): ReadonlyMap<string, TabSession> {
|
|
541
|
+
return tabs;
|
|
542
|
+
}
|
|
543
|
+
|
|
470
544
|
function isLastSurfaceCloseError(err: unknown): boolean {
|
|
471
545
|
const message = err instanceof Error ? err.message : String(err);
|
|
472
546
|
return /last/i.test(message);
|
|
@@ -583,6 +657,45 @@ function toErrorPayload(error: unknown): RunErrorPayload {
|
|
|
583
657
|
return { name: "Error", message: String(error), isAbort: false, isToolError: false };
|
|
584
658
|
}
|
|
585
659
|
|
|
660
|
+
async function recycleTimedOutWorkerTab(tab: WorkerTabSession, timeoutMs: number): Promise<void> {
|
|
661
|
+
const oldWorker = tab.worker;
|
|
662
|
+
await oldWorker.terminate().catch(() => undefined);
|
|
663
|
+
const browserWSEndpoint = tab.browser.browser.wsEndpoint();
|
|
664
|
+
if (!browserWSEndpoint) throw new ToolError("Browser websocket endpoint is unavailable");
|
|
665
|
+
const payload: WorkerInitPayload = {
|
|
666
|
+
mode: "attach",
|
|
667
|
+
browserWSEndpoint,
|
|
668
|
+
safeDir: getPuppeteerDir(),
|
|
669
|
+
targetId: tab.targetId,
|
|
670
|
+
dialogs: tab.dialogPolicy,
|
|
671
|
+
};
|
|
672
|
+
let worker = await spawnTabWorker();
|
|
673
|
+
try {
|
|
674
|
+
const info = await initializeTabWorker(worker, payload, timeoutMs);
|
|
675
|
+
tab.worker = worker;
|
|
676
|
+
tab.info = info;
|
|
677
|
+
tab.state = "alive";
|
|
678
|
+
worker.onMessage(msg => handleTabMessage(tab, msg));
|
|
679
|
+
} catch (error) {
|
|
680
|
+
await worker.terminate().catch(() => undefined);
|
|
681
|
+
worker = await spawnInlineWorker();
|
|
682
|
+
try {
|
|
683
|
+
const info = await initializeTabWorker(worker, payload, timeoutMs);
|
|
684
|
+
tab.worker = worker;
|
|
685
|
+
tab.info = info;
|
|
686
|
+
tab.state = "alive";
|
|
687
|
+
worker.onMessage(msg => handleTabMessage(tab, msg));
|
|
688
|
+
} catch (inlineError) {
|
|
689
|
+
await worker.terminate().catch(() => undefined);
|
|
690
|
+
const finalError = new ToolError(
|
|
691
|
+
`Failed to recycle timed-out browser tab worker (inline fallback also failed): ${inlineError instanceof Error ? inlineError.message : String(inlineError)}`,
|
|
692
|
+
);
|
|
693
|
+
Object.defineProperty(finalError, "cause", { value: error, configurable: true });
|
|
694
|
+
throw finalError;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
586
699
|
async function forceKillTab(name: string, reason: string): Promise<void> {
|
|
587
700
|
const tab = tabs.get(name);
|
|
588
701
|
if (!tab) return;
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
loadPuppeteerInWorker,
|
|
37
37
|
} from "./launch";
|
|
38
38
|
import { extractReadableFromHtml, type ReadableFormat } from "./readable";
|
|
39
|
+
import { waitForBrowserRun } from "./run-cancellation";
|
|
39
40
|
import type {
|
|
40
41
|
Observation,
|
|
41
42
|
ObservationEntry,
|
|
@@ -481,13 +482,13 @@ async function clickQueryHandlerText(
|
|
|
481
482
|
const target = await resolveActionableQueryHandlerClickTarget(handles);
|
|
482
483
|
if (!target) {
|
|
483
484
|
lastReason = handles.length ? "no-visible-candidate" : "no-matches";
|
|
484
|
-
await Bun.sleep(100);
|
|
485
|
+
await untilAborted(clickSignal, () => Bun.sleep(100));
|
|
485
486
|
continue;
|
|
486
487
|
}
|
|
487
488
|
const actionability = await isClickActionable(target);
|
|
488
489
|
if (!actionability.ok) {
|
|
489
490
|
lastReason = actionability.reason;
|
|
490
|
-
await Bun.sleep(100);
|
|
491
|
+
await untilAborted(clickSignal, () => Bun.sleep(100));
|
|
491
492
|
continue;
|
|
492
493
|
}
|
|
493
494
|
try {
|
|
@@ -495,7 +496,7 @@ async function clickQueryHandlerText(
|
|
|
495
496
|
return;
|
|
496
497
|
} catch (err) {
|
|
497
498
|
lastReason = err instanceof Error ? err.message : String(err);
|
|
498
|
-
await Bun.sleep(100);
|
|
499
|
+
await untilAborted(clickSignal, () => Bun.sleep(100));
|
|
499
500
|
}
|
|
500
501
|
} finally {
|
|
501
502
|
await Promise.all(handles.map(async handle => handle.dispose().catch(() => undefined)));
|
|
@@ -515,6 +516,7 @@ export interface InflightOp {
|
|
|
515
516
|
interface ActiveRun {
|
|
516
517
|
id: string;
|
|
517
518
|
ac: AbortController;
|
|
519
|
+
signal: AbortSignal;
|
|
518
520
|
displays: RunResultOk["displays"];
|
|
519
521
|
screenshots: ScreenshotResult[];
|
|
520
522
|
pendingTools: Map<string, { resolve(value: unknown): void; reject(error: Error): void }>;
|
|
@@ -697,12 +699,14 @@ export class WorkerCore {
|
|
|
697
699
|
}
|
|
698
700
|
const timeoutSignal = AbortSignal.timeout(msg.timeoutMs);
|
|
699
701
|
const ac = new AbortController();
|
|
700
|
-
const
|
|
702
|
+
const runAc = new AbortController();
|
|
703
|
+
const signal = AbortSignal.any([timeoutSignal, ac.signal, runAc.signal]);
|
|
701
704
|
const displays: RunResultOk["displays"] = [];
|
|
702
705
|
const screenshots: ScreenshotResult[] = [];
|
|
703
706
|
const active: ActiveRun = {
|
|
704
707
|
id: msg.id,
|
|
705
708
|
ac,
|
|
709
|
+
signal,
|
|
706
710
|
displays,
|
|
707
711
|
screenshots,
|
|
708
712
|
pendingTools: new Map(),
|
|
@@ -724,7 +728,7 @@ export class WorkerCore {
|
|
|
724
728
|
assert: (cond: unknown, text?: string): void => {
|
|
725
729
|
if (!cond) throw new ToolError(text ?? "Assertion failed");
|
|
726
730
|
},
|
|
727
|
-
wait: (ms: number): Promise<void> =>
|
|
731
|
+
wait: (ms: number): Promise<void> => waitForBrowserRun(ms, signal),
|
|
728
732
|
});
|
|
729
733
|
const { promise: cancelRejection, reject: rejectCancel } = Promise.withResolvers<never>();
|
|
730
734
|
const onCancel = (): void => {
|
|
@@ -767,6 +771,7 @@ export class WorkerCore {
|
|
|
767
771
|
this.#transport.send({ type: "result", id: msg.id, ok: false, error: errorPayload(error) });
|
|
768
772
|
} finally {
|
|
769
773
|
if (this.#active?.id === msg.id) this.#active = null;
|
|
774
|
+
runAc.abort(new ToolAbortError("Browser run ended"));
|
|
770
775
|
}
|
|
771
776
|
}
|
|
772
777
|
|
|
@@ -783,11 +788,18 @@ export class WorkerCore {
|
|
|
783
788
|
const active = this.#active;
|
|
784
789
|
if (!active) return null;
|
|
785
790
|
return {
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
+
onText: chunk => {
|
|
792
|
+
throwIfAborted(active.signal);
|
|
793
|
+
this.#log("debug", chunk.replace(/\n$/, ""));
|
|
794
|
+
},
|
|
795
|
+
onDisplay: output => {
|
|
796
|
+
throwIfAborted(active.signal);
|
|
797
|
+
this.#pushDisplay(active.displays, output);
|
|
798
|
+
},
|
|
799
|
+
callTool: (name, args) => {
|
|
800
|
+
throwIfAborted(active.signal);
|
|
801
|
+
return this.#callTool(active, name, args);
|
|
802
|
+
},
|
|
791
803
|
};
|
|
792
804
|
}
|
|
793
805
|
|
package/src/tools/browser.ts
CHANGED
package/src/tools/eval.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { defaultEvalSessionId } from "../eval/session-id";
|
|
|
10
10
|
import type { EvalCellResult, EvalDisplayOutput, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types";
|
|
11
11
|
import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
|
|
12
12
|
import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
|
|
13
|
+
import { resolveSpawnPolicy } from "../task/spawn-policy";
|
|
13
14
|
import { webpExclusionForModel } from "../utils/image-loading";
|
|
14
15
|
import { formatDimensionNote, resizeImage } from "../utils/image-resize";
|
|
15
16
|
import type { ToolSession } from ".";
|
|
@@ -164,13 +165,10 @@ export interface EvalToolDescriptionOptions {
|
|
|
164
165
|
rb?: boolean;
|
|
165
166
|
jl?: boolean;
|
|
166
167
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* compatibility — when the session forbids spawning, the prelude doc
|
|
170
|
-
* omits the `agent()` entry so the model does not promise itself a
|
|
171
|
-
* helper that will only ever throw "spawns disabled".
|
|
168
|
+
* Parent spawn policy (`getSessionSpawns`). `true`/omitted means unrestricted,
|
|
169
|
+
* `false`/`""` hides `agent()`, and a comma list drives the advertised default.
|
|
172
170
|
*/
|
|
173
|
-
spawns?: boolean;
|
|
171
|
+
spawns?: boolean | string | null;
|
|
174
172
|
}
|
|
175
173
|
|
|
176
174
|
export function getEvalToolDescription(options: EvalToolDescriptionOptions = {}): string {
|
|
@@ -178,8 +176,16 @@ export function getEvalToolDescription(options: EvalToolDescriptionOptions = {})
|
|
|
178
176
|
const js = options.js ?? true;
|
|
179
177
|
const rb = options.rb ?? false;
|
|
180
178
|
const jl = options.jl ?? false;
|
|
181
|
-
const
|
|
182
|
-
return prompt.render(evalDescription, {
|
|
179
|
+
const spawnPolicy = resolveSpawnPolicy(options.spawns ?? true);
|
|
180
|
+
return prompt.render(evalDescription, {
|
|
181
|
+
py,
|
|
182
|
+
js,
|
|
183
|
+
rb,
|
|
184
|
+
jl,
|
|
185
|
+
spawns: spawnPolicy.enabled,
|
|
186
|
+
spawnDefaultAgent: spawnPolicy.defaultAgent,
|
|
187
|
+
spawnAllowedAgentsText: spawnPolicy.allowedPromptText,
|
|
188
|
+
});
|
|
183
189
|
}
|
|
184
190
|
|
|
185
191
|
export interface EvalToolOptions {
|
|
@@ -294,13 +300,12 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
|
|
|
294
300
|
if (!this.session) return getEvalToolDescription();
|
|
295
301
|
const backends = resolveEvalBackends(this.session);
|
|
296
302
|
const sessionSpawns = this.session.getSessionSpawns?.() ?? "*";
|
|
297
|
-
const spawnsAllowed = sessionSpawns !== "" && sessionSpawns !== null;
|
|
298
303
|
return getEvalToolDescription({
|
|
299
304
|
py: backends.python,
|
|
300
305
|
js: backends.js,
|
|
301
306
|
rb: backends.ruby,
|
|
302
307
|
jl: backends.julia,
|
|
303
|
-
spawns:
|
|
308
|
+
spawns: sessionSpawns,
|
|
304
309
|
});
|
|
305
310
|
}
|
|
306
311
|
/** All reuse-chain examples; the `examples` getter filters by enabled languages. */
|