Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/integrations/ego-source-reader.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 38 additions & 3 deletions loopx/workflow_skills/ego_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions tests/test_ego_skill_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys

Expand Down Expand Up @@ -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
):
Expand Down
Loading