From be2c23e1ac4c6daee57c714bfe7729f63f7c9b66 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:22:35 +0800 Subject: [PATCH] fix(ego): resume authorized tasks from live browser control Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/integrations/ego-source-reader.md | 8 ++ loopx/workflow_skills/ego_delivery.py | 41 +++++++++- .../references/installation-and-diagnosis.md | 8 ++ tests/test_ego_skill_delivery.py | 82 +++++++++++++++++++ 4 files changed, 136 insertions(+), 3 deletions(-) diff --git a/docs/integrations/ego-source-reader.md b/docs/integrations/ego-source-reader.md index 2e999c4596..2562013f74 100644 --- a/docs/integrations/ego-source-reader.md +++ b/docs/integrations/ego-source-reader.md @@ -215,6 +215,14 @@ transport does not grant control or prove a live human handoff was tested. A source's actual login or verification requirement remains a separate boundary; follow the installed Ego skill and continue independent work. +The direct `ego-browser` CLI used for interactive UI work is a separate route. +LoopX's existing [host-skill refresh](../../skills/loopx-project/references/installation-and-diagnosis.md#ego-host-skill-refresh) +delivers its same-task continuation guidance. Before repeating an old control +question, inspect live inventory once: Agent control restored for the same task +allows continuation without another chat approval. Normal handback can qualify +one claim; a live user stop, explicit pause or unresolved user step still holds. +Neither this reader nor a stored STOP note is a second browser-ownership owner. + On normal MCP shutdown or SIGTERM, it finishes only its own created, still-agent-owned space, checking live control rather than the handle's creation snapshot before resolving it. Missing spaces, unreadable state and user control diff --git a/loopx/workflow_skills/ego_delivery.py b/loopx/workflow_skills/ego_delivery.py index e11cfb4d79..50a6dd53f6 100644 --- a/loopx/workflow_skills/ego_delivery.py +++ b/loopx/workflow_skills/ego_delivery.py @@ -29,6 +29,32 @@ after your own normal handback, or replacing its confirmed-missing TaskSpace. The replacement becomes that task's single active space. These exceptions do not override a current user-control stop or authorize a different task. +Apply this section after reading `ego-browser skill` too: that version-matched +guide supplies the native APIs; it does not erase these continuation rules. + +Before repeating a browser-control question in a later round, inspect +`listTaskSpaces()` once without constructing a handle or touching a Page. +Match the recorded numeric id and task identity. A historical error, STOP note +or carried-over summary is not evidence of a new user intervention. Keep the +original observation and distinguish it from the current control state: + +- If Ego now reports the same task as Agent-owned, continue with `taskSpace(id)` + without a claim, takeover or chat confirmation. An old native control stop + does not require another approval after Ego itself returns Agent control. +- If it remains user-owned/inactive without new return authorization, only the + qualified normal-handback rule below permits one claim. Otherwise preserve + the stop and ask only about the unresolved browser step/control choice. Do + not describe unknown history as a confirmed new user takeover. +- An explicit user pause/control reservation or an unresolved login, permission + prompt or device chooser still blocks the affected action, even if inventory + reports Agent ownership. Inventory is not proof that those steps are resolved. +- After the user explicitly authorizes return for this same task, consume that + authorization once through the documented native recovery API. Do not ask + again merely because the earlier STOP remains in task history. A subsequent + live stop needs its own current observation; do not retry around it. + +This is a bounded state check when continuing authorized work, not background +polling, automatic takeover or permission to clear a live user reservation. For the same already-authorized task, reuse its known numeric TaskSpace and Page without another ownership-confirmation question when your previous normal @@ -83,6 +109,13 @@ ```js const space = (await listTaskSpaces()).find(s => s.id === knownSpaceId); if (!space || space.taskId !== knownTaskId) throw new Error("Task identity changed"); +// These facts come from the current task, not inferred from ownership alone. +if (explicitPauseOrControlReservation || unresolvedUserStep) { + throw new Error("Browser action still belongs to the user"); +} +if (space.ownership !== "agent" && !qualifiedNormalHandback) { + throw new Error("Browser control has not returned"); +} const task = space.ownership === "agent" ? await taskSpace(knownSpaceId) : await claimTaskSpace(knownSpaceId); const tab = (await task.tabs()).find(t => t.url === knownPageUrl); @@ -92,9 +125,11 @@ ``` Stop immediately for active user interaction, an unexpected live ownership -change, an explicit pause, or a user reservation of browser control. Do not -reclaim after a new user-control stop during Agent-owned work, or after the -one qualified claim is refused. An unresolved permission prompt, +change, an explicit pause, or a user reservation of browser control. A new +user-control stop during Agent-owned work or a refused qualified claim ends +that attempt: do not retry, replace the space or switch browser to get around it. +In a later authorized round, apply the live-state check above before carrying +that historical stop forward. An unresolved permission prompt, device chooser, login or verification still belongs to its existing owner. If this is an unrelated space, its task identity is unverified, or a current user-control reservation remains unresolved, ask about that specific choice. diff --git a/skills/loopx-project/references/installation-and-diagnosis.md b/skills/loopx-project/references/installation-and-diagnosis.md index bf97d31b51..e824ec4eee 100644 --- a/skills/loopx-project/references/installation-and-diagnosis.md +++ b/skills/loopx-project/references/installation-and-diagnosis.md @@ -39,6 +39,14 @@ for the same authorized task without asking again merely because the previous round finished or handed it back. Active user intervention, explicit pauses, reserved control and unverified spaces retain the installed Ego skill's stop rules. This is host guidance, not permission to bypass Ego's ownership checks. +Before carrying an old STOP into a new control question, read the live inventory +once. The same task already returned to Agent ownership resumes without a claim +or another chat approval; an explicit pause or unresolved user step still holds. +Normal handback and explicit return approval retain their existing recovery +paths. The dynamic `ego-browser skill` guide owns native API usage and does not +replace this installed continuation policy. The optional read-only source +provider already checks live control on each call; UI automation through the +CLI must apply the same distinction between a historical stop and current control. After an independently authorized Ego upgrade, run `loopx workflow-skills --install` and read `ego_browser`: it follows the current installed Ego source, preserves custom edits and reports recovery steps. It does not upgrade Ego. App-private diff --git a/tests/test_ego_skill_delivery.py b/tests/test_ego_skill_delivery.py index 9eeedc9f98..e021cb86e8 100644 --- a/tests/test_ego_skill_delivery.py +++ b/tests/test_ego_skill_delivery.py @@ -4,6 +4,7 @@ import json import os from pathlib import Path +import shutil import subprocess import sys @@ -65,6 +66,87 @@ def test_refresh_copies_installed_skill_without_editing_app_and_reuses_idle_task ) +@pytest.mark.parametrize( + "scenario,expected", + [ + ( + {"ownership": "agent", "historicalStop": True}, + ["task", "tabs", "page", "snapshot"], + ), + ( + {"ownership": "user", "handback": True}, + ["claim", "tabs", "page", "snapshot"], + ), + ({"ownership": "user"}, []), + ({"ownership": "inactive"}, []), + ({"ownership": "agent", "pause": True}, []), + ({"ownership": "agent", "userStep": True}, []), + ({"ownership": "agent", "wrongTask": True}, []), + ({"ownership": "agent", "liveStop": True}, ["task"]), + ({"ownership": "user", "handback": True, "liveStop": True}, ["claim"]), + ( + {"ownership": "agent", "unmanaged": True}, + ["task", "tabs", "adopt", "snapshot"], + ), + ], +) +def test_installed_continuation_recipe_honors_current_control(host, scenario, expected): + """Run the delivered recipe; history cannot substitute for live control. + + This validates executable guidance, not model compliance or a live human + takeover. Native stops after inventory must never cause another recovery. + """ + node = shutil.which("node") + if node is None: + pytest.skip("Node is required to exercise the delivered Ego recipe") + _, _, _, entry, skills = host + assert workflow_skill_install(skills_dir=skills, execute=True)["ok"] + recipe = ( + (entry / "SKILL.md").read_text().split("```js\n", 1)[1].split("\n```", 1)[0] + ) + harness = r""" +const knownSpaceId = 7, knownTaskId = "browser-check"; +const knownPageUrl = "https://example.com/check"; +const explicitPauseOrControlReservation = !!scenario.pause; +const unresolvedUserStep = !!scenario.userStep; +const qualifiedNormalHandback = !!scenario.handback; +const actions = []; +const page = {async snapshot(){actions.push("snapshot");return "resumed";}}; +const task = { + async tabs(){actions.push("tabs");return [{url:knownPageUrl, + label:scenario.unmanaged ? undefined : "p4",page}];}, + page(label){if(label!=="p4")throw Error("old label reused");actions.push("page");return page;}, + async adopt(original){if(original!==page)throw Error("wrong page");actions.push("adopt");return page;} +}; +let inventoryReads=0; +async function listTaskSpaces(){inventoryReads++;return [{id:knownSpaceId, + taskId:scenario.wrongTask ? "unrelated" : knownTaskId,ownership:scenario.ownership}];} +async function resolve(action){actions.push(action);if(scenario.liveStop)throw Error("native control stop");return task;} +async function taskSpace(){return resolve("task");} +async function claimTaskSpace(){return resolve("claim");} +(async()=>{try{await eval("(async()=>{"+recipe+"})()");}catch{} + console.log(JSON.stringify({actions,inventoryReads}));})(); +""" + result = subprocess.run( + [ + node, + "-e", + "const scenario=" + + json.dumps(scenario) + + ";const recipe=" + + json.dumps(recipe) + + ";" + + harness, + ], + capture_output=True, + text=True, + check=True, + timeout=10, + ) + observed = json.loads(result.stdout.splitlines()[-1]) + assert observed == {"actions": expected, "inventoryReads": 1} + + def test_loopx_refresh_tracks_new_vendor_version_and_rollback_follows_current_source( host, tmp_path, monkeypatch ):