openvibers/openvibe.link Commits

v0.21.0: element refs and receipts in the agent's browser (Desks plan D3) (#67)

OP OpenVibers committed 17 days ago · 8a917340d1 · 9 files, +113 −12

* The agent's browser lists the page's elements as refs and answers each action with a receipt (Desks plan D3)

* v0.21.0: element refs and receipts in the agent's browser (Desks plan D3)

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 4f647c4..5307687 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,8 +5,8 @@
"wave": "T15",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"kind": "client",
-- "version": "0.20.0",
-- "release": "v0.20.0, published 2026-10-27 as a GitHub release (desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",
++ "version": "0.21.0",
++ "release": "v0.21.0, published 2026-10-27 as a GitHub release (element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"deployed": false,
ddiff --git a/docs/computer.md b/docs/computer.md
iindex 66b5fba..6736661 100644
---- a/docs/computer.md
++++ b/docs/computer.md
@@@ -121,6 +121,15 @@ text and keys are passed to the tool as arguments, never through a shell, and `k
- **No port.** The Node starts it on the first command with `--remote-debugging-pipe`: it speaks the DevTools protocol over two file descriptors, so nothing else on the machine can drive it.
- **Levels.** `read`, `screenshot` and `close` run at every level. `open`, `click`, `fill`, `press` and `back` are acting commands: refused at `observe`, approved one by one at `ask`. The approval says exactly what would happen: "open https://… in the browser", "type 5 characters into #q in the browser: 'hello'".
- **Your own network.** Below level `full`, `open` refuses an address on the machine's own network (private, loopback or link-local, checked for every address the name resolves to).
++- **Elements and receipts** (Node v0.21.0, the Desks plan's step D3). `open` and `read` list the page's interactive
++ elements, one compact line each: `e7 button "Sign in"`, `e8 textbox "Email" (email)`, `e9 checkbox "Remember me"
++ checked`. That is at most 120 visible links, buttons, fields, boxes and menus, named by their label, text or
++ placeholder. Each element keeps its ref (`data-ov-ref`) while it stays on the page, so `click` or `fill` with the
++ selector `[data-ov-ref="e7"]` acts on exactly that element. An agent works from the page's own structure first and
++ needs a screenshot only when that is not enough.
++- **Receipts.** `click`, `press` and `back` answer with a `receipt` of what they changed: `url_changed`, the first lines
++ that `appeared` (up to 8) and how many lines are `gone`. An agent knows at once whether its click did anything,
++ without another read.
- **Closing.** It closes after `browser_idle_s` without a command and at the kill switch.
- **Platforms.** Linux and macOS; not offered on Windows yet.
ddiff --git a/plugins/computer/openvibe_computer/__init__.py b/plugins/computer/openvibe_computer/__init__.py
iindex 49954c0..c8b2141 100644
---- a/plugins/computer/openvibe_computer/__init__.py
++++ b/plugins/computer/openvibe_computer/__init__.py
@@@ -143,7 +143,7 @@ def _int(v, name, lo, hi, default):
class Computer(Plugin):
driver = "computer"
-- version = "0.9.0"
++ version = "0.10.0"
motion_kinds = frozenset()
telemetry_interval_s = 2.0
ddiff --git a/plugins/computer/openvibe_computer/browser.py b/plugins/computer/openvibe_computer/browser.py
iindex 3958d76..64b54fb 100644
---- a/plugins/computer/openvibe_computer/browser.py
++++ b/plugins/computer/openvibe_computer/browser.py
@@@ -22,6 +22,8 @@ import time
MAX_TEXT = 20000
STARTUP_S = 45.0
MAX_LINKS = 60
++MAX_ELEMENTS = 120
++MAX_APPEARED = 8
KEY_CODES = {
"Enter": (13, "\r"), "Tab": (9, "\t"), "Escape": (27, ""), "Backspace": (8, ""), "Delete": (46, ""),
"ArrowUp": (38, ""), "ArrowDown": (40, ""), "ArrowLeft": (37, ""), "ArrowRight": (39, ""),
@@@ -33,14 +35,57 @@ MAC_APPS = ("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser")
++# The page as text, its links, and its interactive elements as refs (Desks plan D3: act on refs from the page's own
++# structure before ever reaching for a screenshot). An element keeps its ref (data-ov-ref) for as long as it stays on
++# the page, so a ref from one read still names the same element after the page changed around it. Each element is one
++# compact line, `e7 button "Sign in"`, which costs a model a fraction of a screenshot.
SUMMARY_JS = """(() => {
const text = (document.body ? document.body.innerText : '').replace(/\\n{3,}/g, '\\n\\n');
const links = [...document.querySelectorAll('a[href]')]
.filter((a) => /^https?:/.test(a.href))
.slice(0, %d)
.map((a) => ({ text: (a.innerText || a.getAttribute('aria-label') || '').trim().slice(0, 100), href: a.href }));
-- return { url: location.href, title: document.title, text: text.slice(0, %d), truncated: text.length > %d, links };
--})()""" % (MAX_LINKS, MAX_TEXT, MAX_TEXT)
++ const SEL = 'a[href], button, input:not([type=hidden]), select, textarea, summary, [contenteditable=""], [contenteditable=true],'
++ + ' [role=button], [role=link], [role=checkbox], [role=radio], [role=tab], [role=menuitem], [role=switch], [role=option], [role=combobox]';
++ const shown = (el) => { const r = el.getClientRects(); if (!r.length) return false; const cs = getComputedStyle(el); return cs.visibility !== 'hidden' && cs.display !== 'none'; };
++ const roleOf = (el) => el.getAttribute('role') || ({ A: 'link', BUTTON: 'button', SELECT: 'select', TEXTAREA: 'textbox', SUMMARY: 'button' })[el.tagName]
++ || (el.tagName === 'INPUT' ? ({ checkbox: 'checkbox', radio: 'radio', submit: 'button', button: 'button', reset: 'button', range: 'slider', file: 'file' })[el.type] || 'textbox' : 'textbox');
++ const labelOf = (el) => (el.labels && el.labels.length ? el.labels[0].innerText : '');
++ const valueName = (el) => (['checkbox', 'radio'].includes(el.type) ? '' : el.value);
++ const nameOf = (el) => (el.getAttribute('aria-label') || labelOf(el) || el.innerText || valueName(el) || el.getAttribute('placeholder') || el.getAttribute('title') || el.getAttribute('alt') || el.getAttribute('name') || '').replace(/\\s+/g, ' ').trim().slice(0, 80);
++ window.__ovRef = window.__ovRef || 0;
++ const elements = [];
++ for (const el of document.querySelectorAll(SEL)) {
++ if (elements.length >= %d) break;
++ if (!shown(el)) continue;
++ if (!el.dataset.ovRef) el.dataset.ovRef = 'e' + (++window.__ovRef);
++ const role = roleOf(el);
++ let line = el.dataset.ovRef + ' ' + role + ' ' + JSON.stringify(nameOf(el));
++ if (role === 'textbox' && el.tagName === 'INPUT' && el.type && el.type !== 'text') line += ' (' + el.type + ')';
++ if (el.checked) line += ' checked';
++ if (el.disabled) line += ' disabled';
++ if (role === 'textbox' && el.value) line += ' value=' + JSON.stringify(String(el.value).slice(0, 40));
++ elements.push(line);
++ }
++ return { url: location.href, title: document.title, text: text.slice(0, %d), truncated: text.length > %d, links, elements };
++})()""" % (MAX_LINKS, MAX_ELEMENTS, MAX_TEXT, MAX_TEXT)
++
++# What the page says before an action, to tell what the action changed (its receipt).
++BEFORE_JS = """(() => ({ url: location.href, text: (document.body ? document.body.innerText : '').slice(0, 60000) }))()"""
++
++
++def receipt(before, after):
++ """What an action changed (Desks plan D3), from the page before and after it: whether it went to another address,
++ the first lines that appeared (MAX_APPEARED), and how many lines went away. An agent reads at once whether its click
++ did anything, without another read or a screenshot."""
++ if not isinstance(before, dict):
++ return None
++ old = [l.strip() for l in str(before.get("text") or "").splitlines() if l.strip()]
++ new = [l.strip() for l in str(after.get("text") or "").splitlines() if l.strip()]
++ old_set, new_set = set(old), set(new)
++ appeared = [l[:160] for l in new if l not in old_set][:MAX_APPEARED]
++ gone = len([l for l in old_set if l not in new_set])
++ return {"url_changed": before.get("url") != after.get("url"), "appeared": appeared, "gone": gone}
CLICK_JS = """((selector, text) => {
let el = null;
@@@ -258,13 +303,27 @@ class Browser:
self._need()
return self.summary()
++ def _before(self):
++ try:
++ return self._eval(BEFORE_JS)
++ except BrowserError:
++ return None
++
++ def _with_receipt(self, before):
++ after = self.summary()
++ r = receipt(before, after)
++ if r is not None:
++ after["receipt"] = r
++ return after
++
def click(self, selector=None, text=None):
self._need()
++ before = self._before()
self._mark()
if not self._eval(CLICK_JS % (json.dumps(selector), json.dumps(text or ""))):
raise BrowserError("bad_value", "nothing on the page matches %s" % (selector or repr(text)))
self._settle()
-- return self.summary()
++ return self._with_receipt(before)
def fill(self, selector, text):
self._need()
@@@ -276,19 +335,21 @@ class Browser:
def press(self, key):
self._need()
code, text = KEY_CODES[key]
++ before = self._before()
self._mark()
base = {"key": key, "code": key if key != "Space" else "Space", "windowsVirtualKeyCode": code, "nativeVirtualKeyCode": code}
self.call("Input.dispatchKeyEvent", dict(base, type="keyDown", **({"text": text} if text else {})))
self.call("Input.dispatchKeyEvent", dict(base, type="keyUp"))
self._settle()
-- return self.summary()
++ return self._with_receipt(before)
def back(self):
self._need()
++ before = self._before()
self._mark()
self._eval("history.back()")
self._settle()
-- return self.summary()
++ return self._with_receipt(before)
def screenshot(self, max_width=1280):
self._need()
ddiff --git a/plugins/computer/pyproject.toml b/plugins/computer/pyproject.toml
iindex 0c67c6e..481640a 100644
---- a/plugins/computer/pyproject.toml
++++ b/plugins/computer/pyproject.toml
@@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "openvibe-computer"
--version = "0.9.0"
++version = "0.10.0"
description = "OpenVibe Node computer plugin: the machine's terminal, files and screen under the owner's control level"
requires-python = ">=3.9"
license = { text = "AGPL-3.0-or-later" }
ddiff --git a/plugins/computer/tests/test_browser.py b/plugins/computer/tests/test_browser.py
iindex 02d1548..7f8a18a 100644
---- a/plugins/computer/tests/test_browser.py
++++ b/plugins/computer/tests/test_browser.py
@@@ -160,11 +160,28 @@ class BrowserKindTest(unittest.TestCase):
self.assertEqual(reply(self.out, self.cmd({"action": "close"}))["data"], {"done": True})
++class ReceiptTest(unittest.TestCase):
++ def test_what_an_action_changed(self):
++ from openvibe_computer.browser import receipt
++ before = {"url": "https://a/", "text": "One\nTwo\n\nThree"}
++ self.assertEqual(receipt(before, {"url": "https://a/", "text": "One\nTwo\nThree"}), {"url_changed": False, "appeared": [], "gone": 0})
++ self.assertEqual(receipt(before, {"url": "https://a/#x", "text": "One\nFour\nThree\n" + "\n".join("n%d" % i for i in range(20))}),
++ {"url_changed": True, "appeared": ["Four"] + ["n%d" % i for i in range(7)], "gone": 1})
++ self.assertIsNone(receipt(None, {"url": "x", "text": ""}), "no receipt without a before")
++ long = receipt(before, {"url": "https://a/", "text": "x" * 500})
++ self.assertEqual(len(long["appeared"][0]), 160)
++
++
class Handler(http.server.SimpleHTTPRequestHandler):
PAGES = {
"/": b"<!doctype html><title>Home</title><h1>Hello</h1><a href='/two'>Second page</a>"
b"<form action='/two'><input id='q' name='q'><button>Search</button></form>",
"/two": b"<!doctype html><title>Two</title><p>Arrived</p>",
++ # Desks plan D3: a page whose button changes the page without going anywhere, and things that are not elements.
++ "/app": b"<!doctype html><title>App</title><p>Ready</p><button id='go' aria-label='Add a row'>+</button>"
++ b"<input type='email' placeholder='Email'><input type='checkbox' checked><a href='/two'>Second page</a>"
++ b"<button style='display:none'>Hidden</button><div id='rows'></div>"
++ b"<script>document.getElementById('go').onclick = () => { document.getElementById('rows').insertAdjacentHTML('beforeend', '<p>Row added</p>'); document.querySelector('p').remove(); };</script>",
}
def do_GET(self):
@@@ -200,6 +217,20 @@ class RealBrowserTest(unittest.TestCase):
self.assertTrue(b.press("Enter")["url"].endswith("/two?q=two+words"))
shot = b.screenshot(640)
self.assertEqual((shot["format"], shot["width"] <= 1280), ("jpeg", True))
++ # Desks plan D3: the page's interactive elements as refs, and a receipt for each action.
++ app = b.open(base + "/app")
++ refs = {line.split(" ", 2)[2].split('"')[1]: line.split(" ", 1)[0] for line in app["elements"]}
++ self.assertEqual(set(refs), {"Add a row", "Email", "", "Second page"}, app["elements"])
++ self.assertTrue(any(l.endswith(' "Email" (email)') for l in app["elements"]), "an input says its type")
++ self.assertTrue(any(" checkbox " in l and l.endswith(" checked") for l in app["elements"]), "a checkbox says it is checked")
++ self.assertFalse(any("Hidden" in l for l in app["elements"]), "a hidden element is not offered")
++ done = b.click(selector='[data-ov-ref="%s"]' % refs["Add a row"])
++ self.assertEqual(done["receipt"], {"url_changed": False, "appeared": ["Row added"], "gone": 1})
++ again = b.read()
++ self.assertIn('%s button "Add a row"' % refs["Add a row"], again["elements"], "the same element keeps its ref")
++ away = b.click(selector='[data-ov-ref="%s"]' % refs["Second page"])
++ self.assertTrue(away["receipt"]["url_changed"])
++ self.assertEqual(b.back()["receipt"]["url_changed"], True)
self.assertTrue(shot["content_b64"])
b.close()
self.assertFalse(b.alive())
ddiff --git a/plugins/sdk/openvibe_plugin/__init__.py b/plugins/sdk/openvibe_plugin/__init__.py
iindex 90e2520..54d4caf 100644
---- a/plugins/sdk/openvibe_plugin/__init__.py
++++ b/plugins/sdk/openvibe_plugin/__init__.py
@@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run
"RUNTIME_CAUSES", "SHARED_CLOCK", "__version__"]
# The Node release this runtime ships with (pyproject.toml carries the same).
--__version__ = "0.20.0"
++__version__ = "0.21.0"
PROTOCOL_VERSION = 1
HEARTBEAT_TIMEOUT_S = 1.0
DEFAULT_DEADLINE_MS = 300
ddiff --git a/plugins/sdk/pyproject.toml b/plugins/sdk/pyproject.toml
iindex f1bab3e..952677c 100644
---- a/plugins/sdk/pyproject.toml
++++ b/plugins/sdk/pyproject.toml
@@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "openvibe-plugin"
--version = "0.20.0"
++version = "0.21.0"
description = "Runtime for OpenVibe Node plugins (JSON lines over stdin/stdout, deadlines, heartbeat, e-stop)"
requires-python = ">=3.9"
license = { text = "AGPL-3.0-or-later" }
ddiff --git a/plugins/sdk/tests/test_runtime.py b/plugins/sdk/tests/test_runtime.py
iindex 9602c56..5fc6ba5 100644
---- a/plugins/sdk/tests/test_runtime.py
++++ b/plugins/sdk/tests/test_runtime.py
@@@ -439,7 +439,7 @@ class RuntimeTest(unittest.TestCase):
self.assertEqual(rt.stop_infos[-1].halt_epoch, 1792900000500)
def test_the_package_reports_the_node_release(self):
-- self.assertEqual(openvibe_plugin.__version__, "0.20.0")
++ self.assertEqual(openvibe_plugin.__version__, "0.21.0")
with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:
self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())