@melaya/runner 1.1.8 → 1.1.10
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/dist/assistantHost.py +7 -0
- package/dist/browserBridge.d.ts +19 -0
- package/dist/browserBridge.js +156 -3
- package/dist/connection.js +22 -0
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -465,6 +465,13 @@ def _build_agent():
|
|
|
465
465
|
_budget = int(os.environ.get("MEL_LAZY_BUDGET", "25"))
|
|
466
466
|
if phone_enabled:
|
|
467
467
|
_budget = max(_budget, 64)
|
|
468
|
+
# Same for browser: the browser_* set (nav/click/type/scroll/screen-tree/
|
|
469
|
+
# screenshot/tabs/...) is ~24 tools and must ALL stay pinned+active, or the
|
|
470
|
+
# default 25 budget spills the tail (e.g. browser_open_tab) into the lazy
|
|
471
|
+
# pool where activate_tool then fails with "budget reached". Widen so tab
|
|
472
|
+
# management + every browser action is directly callable, no search/activate.
|
|
473
|
+
if browser_enabled:
|
|
474
|
+
_budget = max(_budget, 64)
|
|
468
475
|
toolkit = build_lazy_toolkit(
|
|
469
476
|
active_categories=categories,
|
|
470
477
|
include_categories=categories + core_categories + connector_services,
|
package/dist/browserBridge.d.ts
CHANGED
|
@@ -90,6 +90,25 @@ export interface BrowserBridge {
|
|
|
90
90
|
* grant-scoped run sessions (byRunId).
|
|
91
91
|
*/
|
|
92
92
|
killInteractive(sessionId?: string): Promise<string[]>;
|
|
93
|
+
/**
|
|
94
|
+
* Set or clear the takeover pause flag (Fix 3).
|
|
95
|
+
*
|
|
96
|
+
* Called by the connection layer when the server emits a
|
|
97
|
+
* `browser:takeover { sessionId?, paused }` socket event.
|
|
98
|
+
*
|
|
99
|
+
* While paused, `performAct` blocks all consequential act kinds
|
|
100
|
+
* (navigate, click, tap, input_text, press_key, scroll,
|
|
101
|
+
* select_option, upload_file, submit, back, forward, batch,
|
|
102
|
+
* ask_user) and returns a `BridgeError("paused_by_user", ...)` so
|
|
103
|
+
* the agent stops trying and waits. Read-only ops (get_screen_tree,
|
|
104
|
+
* screenshot, get_text, current_target, wait,
|
|
105
|
+
* wait_for_network_idle) are not blocked.
|
|
106
|
+
*
|
|
107
|
+
* If sessionId is provided, only that interactive session is paused.
|
|
108
|
+
* If sessionId is omitted, ALL sessions on this runner are paused.
|
|
109
|
+
* The flag is cleared on teardownRun / session teardown.
|
|
110
|
+
*/
|
|
111
|
+
setPaused(paused: boolean, sessionId?: string): void;
|
|
93
112
|
shutdown(): Promise<void>;
|
|
94
113
|
}
|
|
95
114
|
export declare function startBrowserBridge(opts: {
|
package/dist/browserBridge.js
CHANGED
|
@@ -96,7 +96,20 @@ const KIND_MIN_EFFECT = {
|
|
|
96
96
|
click: "read", dblclick: "read", hover: "read", scroll: "read",
|
|
97
97
|
input_text: "read", press_key: "read", select_option: "read",
|
|
98
98
|
wait: "read",
|
|
99
|
+
// Added kinds (Fix 1 + Fix 2):
|
|
100
|
+
tap: "read", // coordinate click; same effect ceiling as click
|
|
101
|
+
get_text: "read", // read-only page text extraction
|
|
102
|
+
wait_for_network_idle: "read", // read-only network wait
|
|
99
103
|
};
|
|
104
|
+
// Consequential act kinds blocked while the user has taken over the browser.
|
|
105
|
+
// Read-only kinds (get_text, get_screen_tree path, screenshot, wait,
|
|
106
|
+
// wait_for_network_idle) are intentionally excluded so the agent can observe.
|
|
107
|
+
const CONSEQUENTIAL_KINDS = new Set([
|
|
108
|
+
"navigate", "back", "forward",
|
|
109
|
+
"click", "dblclick", "hover", "tap",
|
|
110
|
+
"input_text", "press_key", "scroll", "select_option",
|
|
111
|
+
"upload_file", "submit", "ask_user", "batch",
|
|
112
|
+
]);
|
|
100
113
|
// ---------------------------------------------------------------------
|
|
101
114
|
// Bridge
|
|
102
115
|
// ---------------------------------------------------------------------
|
|
@@ -129,6 +142,11 @@ export async function startBrowserBridge(opts) {
|
|
|
129
142
|
// getInteractiveSession and setWatchLease can find them without iterating
|
|
130
143
|
// the full grant-scoped run registry.
|
|
131
144
|
const interactiveSessions = new Map();
|
|
145
|
+
// Takeover pause state (Fix 3).
|
|
146
|
+
// A sessionId entry means that specific interactive session is paused.
|
|
147
|
+
// The sentinel key "" means ALL sessions on this runner are paused.
|
|
148
|
+
// Cleared per-session on teardown, or globally on teardownAll.
|
|
149
|
+
const pausedSessions = new Set();
|
|
132
150
|
let playwrightMod = null;
|
|
133
151
|
async function pw() {
|
|
134
152
|
if (!playwrightMod)
|
|
@@ -729,6 +747,17 @@ export async function startBrowserBridge(opts) {
|
|
|
729
747
|
}
|
|
730
748
|
throw new BridgeError("act_args_invalid", `kind '${args.kind}' needs a ref or x/y coordinates`);
|
|
731
749
|
}
|
|
750
|
+
// Returns true if the given run's session is currently paused by a takeover.
|
|
751
|
+
// The global sentinel "" pauses all sessions; a specific sessionId pauses
|
|
752
|
+
// only sessions attached to that interactive session.
|
|
753
|
+
function isActPaused(reg) {
|
|
754
|
+
if (pausedSessions.has(""))
|
|
755
|
+
return true;
|
|
756
|
+
const browserSessionId = String(reg.spec.grant?.browserSession || "");
|
|
757
|
+
if (browserSessionId && pausedSessions.has(browserSessionId))
|
|
758
|
+
return true;
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
732
761
|
async function performAct(reg, args) {
|
|
733
762
|
const kind = String(args.kind || "");
|
|
734
763
|
if (!(kind in KIND_MIN_EFFECT)) {
|
|
@@ -744,6 +773,13 @@ export async function startBrowserBridge(opts) {
|
|
|
744
773
|
if (!d.allowed)
|
|
745
774
|
throw new BridgeError(d.code, d.message);
|
|
746
775
|
}
|
|
776
|
+
// Takeover pause gate (Fix 3): block consequential acts while the user
|
|
777
|
+
// has manually taken over the browser. Read-only kinds pass through.
|
|
778
|
+
if (CONSEQUENTIAL_KINDS.has(kind) && isActPaused(reg)) {
|
|
779
|
+
throw new BridgeError("paused_by_user", "The user has taken manual control of the browser; agent actions are " +
|
|
780
|
+
"paused until they hand control back. Wait and retry, or call " +
|
|
781
|
+
"browser_get_screen_tree / browser_screenshot to observe the page.");
|
|
782
|
+
}
|
|
747
783
|
const { rec, lease } = await getLease(reg);
|
|
748
784
|
return sessions.runOnTarget(lease, async () => {
|
|
749
785
|
if (reg.cancelled)
|
|
@@ -769,6 +805,40 @@ export async function startBrowserBridge(opts) {
|
|
|
769
805
|
case "click":
|
|
770
806
|
case "dblclick":
|
|
771
807
|
case "hover": {
|
|
808
|
+
// Fix 2: if a "text" arg is present and there is no ref or x/y,
|
|
809
|
+
// resolve the text to an element point via Playwright locators
|
|
810
|
+
// (case-insensitive; prefers exact match, falls back to contains;
|
|
811
|
+
// scoped to visible elements). If nothing matches, give the model
|
|
812
|
+
// a clear directive to re-snapshot and use a @eN ref instead.
|
|
813
|
+
if (args.text && !args.ref && args.x === undefined) {
|
|
814
|
+
const label = String(args.text);
|
|
815
|
+
// Try exact visible text first, then partial, then ARIA name.
|
|
816
|
+
let locator = page.getByText(label, { exact: true }).first();
|
|
817
|
+
let box = await locator.boundingBox({ timeout: 2_000 }).catch(() => null);
|
|
818
|
+
if (!box) {
|
|
819
|
+
locator = page.getByText(label, { exact: false }).first();
|
|
820
|
+
box = await locator.boundingBox({ timeout: 2_000 }).catch(() => null);
|
|
821
|
+
}
|
|
822
|
+
if (!box) {
|
|
823
|
+
locator = page.getByRole("button", { name: label, exact: false }).first();
|
|
824
|
+
box = await locator.boundingBox({ timeout: 2_000 }).catch(() => null);
|
|
825
|
+
}
|
|
826
|
+
if (!box) {
|
|
827
|
+
locator = page.getByRole("link", { name: label, exact: false }).first();
|
|
828
|
+
box = await locator.boundingBox({ timeout: 2_000 }).catch(() => null);
|
|
829
|
+
}
|
|
830
|
+
if (!box) {
|
|
831
|
+
throw new BridgeError("act_args_invalid", `click by text: no visible element matched "${label}". ` +
|
|
832
|
+
"Call browser_get_screen_tree to get fresh @eN refs and retry " +
|
|
833
|
+
"with a ref instead of a text label.");
|
|
834
|
+
}
|
|
835
|
+
const pt = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
836
|
+
if (kind === "hover")
|
|
837
|
+
await page.mouse.move(pt.x, pt.y);
|
|
838
|
+
else
|
|
839
|
+
await page.mouse.click(pt.x, pt.y, { clickCount: kind === "dblclick" ? 2 : 1 });
|
|
840
|
+
return { at: { x: Math.round(pt.x), y: Math.round(pt.y) }, matched_text: label };
|
|
841
|
+
}
|
|
772
842
|
const pt = await resolveActionPoint(reg, rec, lease, args);
|
|
773
843
|
if (kind === "hover")
|
|
774
844
|
await page.mouse.move(pt.x, pt.y);
|
|
@@ -776,6 +846,14 @@ export async function startBrowserBridge(opts) {
|
|
|
776
846
|
await page.mouse.click(pt.x, pt.y, { clickCount: kind === "dblclick" ? 2 : 1 });
|
|
777
847
|
return { at: { x: Math.round(pt.x), y: Math.round(pt.y) } };
|
|
778
848
|
}
|
|
849
|
+
case "tap": {
|
|
850
|
+
// Fix 1: tap is a coordinate click (canvas/vision fallback).
|
|
851
|
+
// browser.py sends {x, y} as 0..1 fractions or CSS px.
|
|
852
|
+
// resolveActionPoint already handles the fraction/px heuristic.
|
|
853
|
+
const pt = await resolveActionPoint(reg, rec, lease, args);
|
|
854
|
+
await page.mouse.click(pt.x, pt.y);
|
|
855
|
+
return { at: { x: Math.round(pt.x), y: Math.round(pt.y) } };
|
|
856
|
+
}
|
|
779
857
|
case "input_text": {
|
|
780
858
|
const text = String(args.text ?? args.value ?? "");
|
|
781
859
|
if (text.length > 20_000)
|
|
@@ -801,9 +879,67 @@ export async function startBrowserBridge(opts) {
|
|
|
801
879
|
await cdp.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: binding.backendNodeId }).catch(() => { });
|
|
802
880
|
return { scrolled: "into_view" };
|
|
803
881
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
882
|
+
// Fix 1 (scroll): browser.py sends {value: "small"|"medium"|"large",
|
|
883
|
+
// text: "down"|"up"|"left"|"right"}. Map to pixel deltas.
|
|
884
|
+
// Fall back to args.dy for callers that send raw pixel deltas directly.
|
|
885
|
+
let dx = 0;
|
|
886
|
+
let dy = 0;
|
|
887
|
+
if (args.value !== undefined || args.text !== undefined) {
|
|
888
|
+
const amountMap = { small: 200, medium: 600, large: 1400 };
|
|
889
|
+
const amount = amountMap[String(args.value || "medium")] ?? 600;
|
|
890
|
+
const dir = String(args.text || "down").toLowerCase();
|
|
891
|
+
if (dir === "down")
|
|
892
|
+
dy = amount;
|
|
893
|
+
else if (dir === "up")
|
|
894
|
+
dy = -amount;
|
|
895
|
+
else if (dir === "right")
|
|
896
|
+
dx = amount;
|
|
897
|
+
else if (dir === "left")
|
|
898
|
+
dx = -amount;
|
|
899
|
+
else
|
|
900
|
+
dy = amount;
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
dy = Math.max(-4000, Math.min(4000, Number(args.dy ?? 600)));
|
|
904
|
+
}
|
|
905
|
+
await page.mouse.wheel(dx, dy);
|
|
906
|
+
return { scrolled: { dx, dy } };
|
|
907
|
+
}
|
|
908
|
+
case "get_text": {
|
|
909
|
+
// Fix 1: read-only page-text extraction.
|
|
910
|
+
// browser.py sends {ref?: "@eN"} (optional scoped element).
|
|
911
|
+
// The ref arg arrives under args.ref; if absent, read full body text.
|
|
912
|
+
const TEXT_BUDGET = 12_000;
|
|
913
|
+
let text;
|
|
914
|
+
if (args.ref) {
|
|
915
|
+
// Scope to the element's innerText via CDP object resolution.
|
|
916
|
+
const binding = sessions.resolveRef(lease, args.ref);
|
|
917
|
+
const cdp = await getCdp(rec, page);
|
|
918
|
+
const resolved = await cdp.send("DOM.resolveNode", { backendNodeId: binding.backendNodeId });
|
|
919
|
+
const result = await cdp.send("Runtime.callFunctionOn", {
|
|
920
|
+
objectId: resolved.object.objectId,
|
|
921
|
+
functionDeclaration: `function(){ return (this.innerText || this.textContent || "").trim(); }`,
|
|
922
|
+
returnByValue: true,
|
|
923
|
+
});
|
|
924
|
+
text = String(result.result.value ?? "");
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
text = await page.evaluate(() => (document.body?.innerText || document.body?.textContent || "").trim());
|
|
928
|
+
}
|
|
929
|
+
const truncated = text.length > TEXT_BUDGET;
|
|
930
|
+
return {
|
|
931
|
+
text: text.slice(0, TEXT_BUDGET) + (truncated ? `\n...[truncated; ${text.length} chars total]` : ""),
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
case "wait_for_network_idle": {
|
|
935
|
+
// Fix 1: wait for network idle. browser.py sends {value: seconds}.
|
|
936
|
+
const maxMs = 30_000;
|
|
937
|
+
const timeoutMs = Math.min(Math.max(1_000, Math.round(Number(args.value ?? 10) * 1_000)), maxMs);
|
|
938
|
+
await page.waitForLoadState("networkidle", { timeout: timeoutMs }).catch(() => {
|
|
939
|
+
// Timeout is not fatal: the page may have long-polling connections.
|
|
940
|
+
// Return ok anyway; the agent will re-snapshot and observe.
|
|
941
|
+
});
|
|
942
|
+
return { network_idle: true, waited_ms: timeoutMs };
|
|
807
943
|
}
|
|
808
944
|
case "select_option": {
|
|
809
945
|
if (!args.ref)
|
|
@@ -1442,6 +1578,8 @@ export async function startBrowserBridge(opts) {
|
|
|
1442
1578
|
// producer) are preserved.
|
|
1443
1579
|
interactive.targets.delete(reg.spec.grant.target.ref);
|
|
1444
1580
|
}
|
|
1581
|
+
// Clear any per-session pause flag so a restarted run is not stuck.
|
|
1582
|
+
pausedSessions.delete(browserSessionId);
|
|
1445
1583
|
}
|
|
1446
1584
|
log(`browser run detached (non-owner): ${runId.slice(0, 10)} reason=${reason}`);
|
|
1447
1585
|
}
|
|
@@ -1454,6 +1592,8 @@ export async function startBrowserBridge(opts) {
|
|
|
1454
1592
|
}
|
|
1455
1593
|
};
|
|
1456
1594
|
const teardownAll = async (reason) => {
|
|
1595
|
+
// Clear all pause flags so nothing is left paused after a full teardown.
|
|
1596
|
+
pausedSessions.clear();
|
|
1457
1597
|
// Tear down grant-scoped run sessions.
|
|
1458
1598
|
const runIds = [...byRunId.keys()];
|
|
1459
1599
|
// Tear down interactive sessions (those not already covered by byRunId).
|
|
@@ -1472,6 +1612,18 @@ export async function startBrowserBridge(opts) {
|
|
|
1472
1612
|
}),
|
|
1473
1613
|
]);
|
|
1474
1614
|
};
|
|
1615
|
+
// setPaused: set or clear the takeover pause flag (Fix 3).
|
|
1616
|
+
// sessionId present -> pause only that interactive session.
|
|
1617
|
+
// sessionId absent -> pause/resume all sessions (global sentinel "").
|
|
1618
|
+
const setPaused = (paused, sessionId) => {
|
|
1619
|
+
const key = sessionId ?? "";
|
|
1620
|
+
if (paused) {
|
|
1621
|
+
pausedSessions.add(key);
|
|
1622
|
+
}
|
|
1623
|
+
else {
|
|
1624
|
+
pausedSessions.delete(key);
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1475
1627
|
// killInteractive: close one or all interactive sessions (emergency stop).
|
|
1476
1628
|
// Does NOT touch grant-scoped run sessions in byRunId.
|
|
1477
1629
|
const killInteractive = async (sessionId) => {
|
|
@@ -1517,6 +1669,7 @@ export async function startBrowserBridge(opts) {
|
|
|
1517
1669
|
getInteractiveSession,
|
|
1518
1670
|
setWatchLease,
|
|
1519
1671
|
killInteractive,
|
|
1672
|
+
setPaused,
|
|
1520
1673
|
async shutdown() {
|
|
1521
1674
|
// Stop all watch-lease producers before closing sessions.
|
|
1522
1675
|
for (const sid of [...watchLeases.keys()])
|
package/dist/connection.js
CHANGED
|
@@ -360,6 +360,28 @@ export async function connect(opts) {
|
|
|
360
360
|
console.log(chalk.yellow(` ! browser:kill error: ${e?.message || e}`));
|
|
361
361
|
}
|
|
362
362
|
});
|
|
363
|
+
// ── BrowserControlPage takeover pause (browser:takeover) ─────────────
|
|
364
|
+
// The server emits browser:takeover { sessionId?, paused } when the user
|
|
365
|
+
// clicks "Take control" (paused:true) or "Hand back" (paused:false) in
|
|
366
|
+
// the BrowserControlPage UI. While paused, the bridge blocks all
|
|
367
|
+
// consequential acts (click, navigate, input_text, etc.) with a
|
|
368
|
+
// paused_by_user BridgeError so the agent waits instead of racing with
|
|
369
|
+
// the user. Read-only ops (get_screen_tree, screenshot, get_text, wait)
|
|
370
|
+
// are not blocked. If sessionId is absent the pause applies to all
|
|
371
|
+
// sessions on this runner.
|
|
372
|
+
socket.on("browser:takeover", (payload) => {
|
|
373
|
+
const sessionId = payload?.sessionId ? String(payload.sessionId) : undefined;
|
|
374
|
+
const paused = Boolean(payload?.paused);
|
|
375
|
+
if (!browserBridge) {
|
|
376
|
+
if (opts.verbose)
|
|
377
|
+
console.log(chalk.gray(" [browser-takeover] bridge not ready, ignoring"));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
browserBridge.setPaused(paused, sessionId);
|
|
381
|
+
if (opts.verbose) {
|
|
382
|
+
console.log(chalk.gray(` [browser-takeover] paused=${paused} sessionId=${sessionId ? sessionId.slice(0, 16) : "(all)"}`));
|
|
383
|
+
}
|
|
384
|
+
});
|
|
363
385
|
// ── Heartbeat ──────────────────────────────────────────────────────
|
|
364
386
|
setInterval(() => {
|
|
365
387
|
if (socket.connected)
|