mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[pierre] feat/app-agent: drive web apps through the OPENSWARM_APP bridge
Browser agent gains app-control logic + schema, app-builder skill doc, run.sh agent-bridge presence check, and app-agent tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f90975f006
commit
a35bfaaf7d
@@ -9,6 +9,7 @@ Sub-agents appear as visible AgentSession cards on the dashboard.
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
@@ -114,6 +115,110 @@ def _app_bridge_expression(tool_name: str, tool_input: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
# App-bridge readiness. The template ships window.OPENSWARM_APP from first paint
|
||||
# but in a "not ready" state until the app calls register(...). On the agent's
|
||||
# first turn the app may still be mounting (Vite cold-boot is 10-30s), so the
|
||||
# reads poll briefly for the bridge to come up instead of declaring it absent.
|
||||
_BRIDGE_READY_WAIT_MS = 8000
|
||||
_BRIDGE_POLL_INTERVAL_MS = 400
|
||||
|
||||
|
||||
def _parse_bridge_result(result: dict) -> object:
|
||||
"""Decode the JSON string an app-bridge evaluate returns (it always returns
|
||||
JSON text and never throws). Returns the decoded value, or None when it is
|
||||
undecodable or errored at the transport level."""
|
||||
if not isinstance(result, dict) or "error" in result:
|
||||
return None
|
||||
raw = result.get("text")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _bridge_ready(value: object) -> bool:
|
||||
"""True when a decoded describe()/getState() value means a registered bridge.
|
||||
Legacy apps return a plain array (ready); the template stub returns
|
||||
{'__ready': False} until register() runs; None means no bridge present yet."""
|
||||
if isinstance(value, list):
|
||||
return True
|
||||
if isinstance(value, dict):
|
||||
return value.get("__ready") is not False and "__error__" not in value
|
||||
return value is not None
|
||||
|
||||
|
||||
def _app_output_id(browser_id: str) -> str | None:
|
||||
return browser_id[4:] if browser_id.startswith("app:") else None
|
||||
|
||||
|
||||
def _app_workspace_dir(browser_id: str) -> str | None:
|
||||
"""Resolve the on-disk workspace folder for an `app:<output_id>` target."""
|
||||
oid = _app_output_id(browser_id)
|
||||
if not oid:
|
||||
return None
|
||||
try:
|
||||
from backend.apps.outputs.workspace_io import load_output
|
||||
from backend.config.paths import OUTPUTS_WORKSPACE_DIR
|
||||
out = load_output(oid)
|
||||
if not out or not getattr(out, "workspace_id", None):
|
||||
return None
|
||||
return os.path.join(OUTPUTS_WORKSPACE_DIR, out.workspace_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _render_app_controls(describe_value: object) -> tuple[str, str] | None:
|
||||
"""From a decoded describe() value build (rules_md, controls_md). Returns
|
||||
None when the value is not a ready, usable describe."""
|
||||
if isinstance(describe_value, list):
|
||||
rules, controls = "", describe_value
|
||||
elif _bridge_ready(describe_value) and isinstance(describe_value, dict):
|
||||
rules = str(describe_value.get("rules") or "")
|
||||
controls = describe_value.get("controls") or []
|
||||
else:
|
||||
return None
|
||||
lines = ["# Controls", ""]
|
||||
for c in controls:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
row = f"- `{c.get('name', '')}`"
|
||||
if c.get("args"):
|
||||
row += f" args={json.dumps(c['args'])}"
|
||||
if c.get("keys"):
|
||||
row += f" [{c['keys']}]"
|
||||
if c.get("description"):
|
||||
row += f": {c['description']}"
|
||||
lines.append(row)
|
||||
controls_md = "\n".join(lines) + "\n"
|
||||
rules_md = (rules.strip() + "\n") if rules.strip() else ""
|
||||
return rules_md, controls_md
|
||||
|
||||
|
||||
def _persist_app_controls(browser_id: str, describe_value: object) -> None:
|
||||
"""Cache rules.md + controls.md into the app workspace so the agent reads them
|
||||
up front and only re-describes when controls change. Best-effort; written
|
||||
under .openswarm/ so they stay out of the app's own file tree."""
|
||||
rendered = _render_app_controls(describe_value)
|
||||
if not rendered:
|
||||
return
|
||||
folder = _app_workspace_dir(browser_id)
|
||||
if not folder or not os.path.isdir(folder):
|
||||
return
|
||||
rules_md, controls_md = rendered
|
||||
try:
|
||||
cache_dir = os.path.join(folder, ".openswarm")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
with open(os.path.join(cache_dir, "controls.md"), "w", encoding="utf-8") as f:
|
||||
f.write(controls_md)
|
||||
if rules_md:
|
||||
with open(os.path.join(cache_dir, "rules.md"), "w", encoding="utf-8") as f:
|
||||
f.write(rules_md)
|
||||
except Exception:
|
||||
logger.debug("[app-agent] failed to persist controls cache", exc_info=True)
|
||||
|
||||
|
||||
async def execute_browser_tool(
|
||||
tool_name: str, tool_input: dict, browser_id: str, tab_id: str = "",
|
||||
) -> dict:
|
||||
@@ -128,14 +233,28 @@ async def execute_browser_tool(
|
||||
action = "evaluate"
|
||||
expr = _app_bridge_expression(tool_name, tool_input)
|
||||
params = {"expression": expr}
|
||||
request_id = uuid4().hex
|
||||
if _trace:
|
||||
logger.info(f"[app-agent] DISPATCH {tool_name} -> {browser_id} (req {request_id[:8]}) js={expr[:120]}")
|
||||
result = await ws_manager.send_browser_command(
|
||||
request_id, action, browser_id, params, tab_id=tab_id,
|
||||
)
|
||||
if _trace:
|
||||
logger.info(f"[app-agent] RESULT {tool_name} <- {browser_id}: {json.dumps(result)[:300]}")
|
||||
|
||||
async def _eval_once() -> dict:
|
||||
rid = uuid4().hex
|
||||
if _trace:
|
||||
logger.info(f"[app-agent] DISPATCH {tool_name} -> {browser_id} (req {rid[:8]}) js={expr[:120]}")
|
||||
r = await ws_manager.send_browser_command(rid, action, browser_id, params, tab_id=tab_id)
|
||||
if _trace:
|
||||
logger.info(f"[app-agent] RESULT {tool_name} <- {browser_id}: {json.dumps(r)[:300]}")
|
||||
return r
|
||||
|
||||
result = await _eval_once()
|
||||
# Reads poll for the bridge to come up (app still mounting on turn 1).
|
||||
# AppInvoke does not wait: its action either exists right now or it does
|
||||
# not, and a missing action should surface immediately.
|
||||
if tool_name in ("AppDescribe", "AppGetState"):
|
||||
waited = 0
|
||||
while waited < _BRIDGE_READY_WAIT_MS and not _bridge_ready(_parse_bridge_result(result)):
|
||||
await asyncio.sleep(_BRIDGE_POLL_INTERVAL_MS / 1000)
|
||||
waited += _BRIDGE_POLL_INTERVAL_MS
|
||||
result = await _eval_once()
|
||||
if tool_name == "AppDescribe":
|
||||
_persist_app_controls(browser_id, _parse_bridge_result(result))
|
||||
return result
|
||||
|
||||
action = ACTION_MAP.get(tool_name)
|
||||
@@ -572,10 +691,66 @@ async def run_browser_agent(
|
||||
)
|
||||
clear_browser_history(browser_id)
|
||||
prior_messages = []
|
||||
# App mode: read the bridge's rules + controls ONCE up front and front-load
|
||||
# them, so the agent knows the app's purpose and every control before its
|
||||
# first action (no screenshot fumbling) and need not call AppDescribe again
|
||||
# until controls change. This is also the runtime bridge gate: if the bridge
|
||||
# never comes up, fail loudly into the logs + the agent's first message (and,
|
||||
# under OPENSWARM_REQUIRE_BRIDGE=1, end the run rather than UI-fumble).
|
||||
app_front_load = ""
|
||||
if app_mode and not prior_messages:
|
||||
try:
|
||||
_dv = _parse_bridge_result(await execute_browser_tool("AppDescribe", {}, browser_id, tab_id))
|
||||
except Exception:
|
||||
_dv = None
|
||||
logger.debug("[app-agent] startup AppDescribe failed", exc_info=True)
|
||||
_rendered = _render_app_controls(_dv)
|
||||
if _rendered:
|
||||
_rules_md, _controls_md = _rendered
|
||||
_rev = _dv.get("__rev") if isinstance(_dv, dict) else None
|
||||
_block = [
|
||||
"\n\n[The app's bridge is live; its rules and controls were read "
|
||||
"for you. Act directly; do NOT call AppDescribe again unless "
|
||||
"AppGetState reports a changed __rev.]"
|
||||
]
|
||||
if _rules_md.strip():
|
||||
_block.append("App rules / objective:\n" + _rules_md.strip())
|
||||
_block.append(_controls_md.strip())
|
||||
if _rev is not None:
|
||||
_block.append(f"(controls __rev: {_rev})")
|
||||
app_front_load = "\n\n".join(_block)
|
||||
else:
|
||||
_oid = _app_output_id(browser_id) or browser_id
|
||||
_msg = (
|
||||
f"BRIDGE MISSING: window.OPENSWARM_APP not registered - "
|
||||
f"app '{_oid}' is not agent-operable"
|
||||
)
|
||||
logger.error(f"[app-agent] {_msg}")
|
||||
if os.environ.get("OPENSWARM_REQUIRE_BRIDGE") == "1":
|
||||
session.status = "completed"
|
||||
agent_manager._sync_session_close(session)
|
||||
await ws_manager.send_to_session(session_id, "agent:status", {
|
||||
"session_id": session_id, "status": "completed",
|
||||
"session": session.model_dump(mode="json"),
|
||||
})
|
||||
return {
|
||||
"session_id": session_id, "browser_id": browser_id,
|
||||
"summary": f"This app is not agent-operable: {_msg}.",
|
||||
"done": True, "success": False,
|
||||
"action_log": [], "final_screenshot": None,
|
||||
}
|
||||
app_front_load = (
|
||||
f"\n\n[{_msg}. AppDescribe/AppInvoke will not work. Fall back to "
|
||||
"driving the UI directly (BrowserListInteractives, "
|
||||
"BrowserClickIndex, BrowserBatch, BrowserScreenshot). If you "
|
||||
"cannot operate it, say so in Done with success=false.]"
|
||||
)
|
||||
|
||||
# Front-load the prefetched perception into the first user turn so the model
|
||||
# can act immediately (only when this is a fresh conversation; a resumed one
|
||||
# already knows the page). The visible task text stays clean.
|
||||
first_user_content = task + preloaded_perception if (preloaded_perception and not prior_messages) else task
|
||||
_front = preloaded_perception or app_front_load
|
||||
first_user_content = task + _front if (_front and not prior_messages) else task
|
||||
messages: list[dict] = list(prior_messages) + [{"role": "user", "content": first_user_content}]
|
||||
# Seed with the front-loaded reads: they really ran and returned content, so a
|
||||
# read task the agent answers straight from them is NOT a "did nothing" ghost.
|
||||
|
||||
@@ -678,13 +678,16 @@ APP_TOOLS_SCHEMA = [
|
||||
{
|
||||
"name": "AppDescribe",
|
||||
"description": (
|
||||
"Read the app's CURRENT list of actions you can take, straight from the "
|
||||
"app itself (window.OPENSWARM_APP.describe()). Returns an array of "
|
||||
"{name, args, description}. The app's controls are DYNAMIC, they appear "
|
||||
"and disappear as state changes, so call this again after any AppInvoke "
|
||||
"that could add or remove actions; never assume the list is stable. "
|
||||
"Returns null if the app does not expose the bridge (then fall back to "
|
||||
"BrowserListInteractives/BrowserScreenshot)."
|
||||
"Read the app's rules and CURRENT list of actions, straight from the "
|
||||
"app itself (window.OPENSWARM_APP.describe()). Returns "
|
||||
"{rules, controls, __rev}: rules is what the app is and its objective, "
|
||||
"controls is an array of {name, args, description, keys}, and __rev is "
|
||||
"a revision number. (Older apps may return a bare array of controls.) "
|
||||
"These are ALREADY front-loaded into your first message, so you rarely "
|
||||
"need to call this. The app's controls are DYNAMIC: call this again "
|
||||
"ONLY when AppGetState reports a changed __rev (e.g. after an AppInvoke "
|
||||
"added or removed actions). Returns null if the app exposes no bridge "
|
||||
"(then fall back to BrowserListInteractives/BrowserScreenshot)."
|
||||
),
|
||||
"input_schema": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
@@ -693,7 +696,10 @@ APP_TOOLS_SCHEMA = [
|
||||
"description": (
|
||||
"Read a small JSON snapshot of the app's current state "
|
||||
"(window.OPENSWARM_APP.getState()). Use it to check what's on screen "
|
||||
"and to verify an action landed. Returns null if the bridge is absent."
|
||||
"and to verify an action landed. The snapshot includes __rev, the "
|
||||
"controls revision: if it differs from the __rev you were given, the "
|
||||
"controls changed, so call AppDescribe to refresh them. Returns null "
|
||||
"if the bridge is absent."
|
||||
),
|
||||
"input_schema": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
@@ -978,22 +984,25 @@ APP_SYSTEM_PROMPT = (
|
||||
"## How you see and act: the app's own bridge (this is the fast path)\n"
|
||||
"The app exposes a native bridge, window.OPENSWARM_APP, with three calls you "
|
||||
"reach through tools:\n"
|
||||
"- AppDescribe -> the CURRENT list of actions {name, args, description}.\n"
|
||||
"- AppGetState -> a small JSON snapshot of what's on screen.\n"
|
||||
"- AppDescribe -> {rules, controls, __rev}: the app's objective and its "
|
||||
"current actions {name, args, description, keys}.\n"
|
||||
"- AppGetState -> a small JSON snapshot of what's on screen (includes __rev).\n"
|
||||
"- AppInvoke(name, args) -> perform one action.\n"
|
||||
"Always start with AppDescribe to learn the real action names and arg shapes, "
|
||||
"then AppInvoke them. This reads the app's true structure directly, so you do "
|
||||
"NOT need screenshots, the DOM, or the accessibility tree.\n"
|
||||
"The app's rules and controls have ALREADY been read for you and placed in "
|
||||
"your first message, so you can start invoking actions immediately; you do "
|
||||
"NOT need screenshots, the DOM, or the accessibility tree, and you usually do "
|
||||
"NOT need to call AppDescribe at all.\n"
|
||||
"Only ever call actions that AppDescribe actually returned. You operate the app, "
|
||||
"you do NOT change it: never invent action names, and never try to add, remove, "
|
||||
"or redefine the app's available actions or edit its code. If what the user wants "
|
||||
"isn't reachable through the exposed actions, say so in Done.\n\n"
|
||||
|
||||
"## Controls are DYNAMIC\n"
|
||||
"Actions and state change as you interact (the app adds and removes controls). "
|
||||
"Never cache the action list: after any AppInvoke that could change what's "
|
||||
"available, call AppDescribe again before relying on it. Verify outcomes with "
|
||||
"AppGetState rather than assuming.\n\n"
|
||||
"## Controls are DYNAMIC, but you only re-read on a __rev change\n"
|
||||
"Actions can change as you interact (the app adds and removes controls). The "
|
||||
"front-loaded controls came with a __rev number. Use AppGetState to verify "
|
||||
"outcomes; if its __rev differs from the one you have, the controls changed, "
|
||||
"so call AppDescribe ONCE to refresh them. As long as __rev is unchanged, "
|
||||
"trust the controls you already have and do not re-describe.\n\n"
|
||||
|
||||
"## If there is no bridge\n"
|
||||
"If AppDescribe (or AppGetState) returns null, this app doesn't expose the "
|
||||
|
||||
@@ -325,58 +325,76 @@ export const JOBS_LIST = '/api/jobs/list';
|
||||
|
||||
---
|
||||
|
||||
## Make the app agent-operable — the `OPENSWARM_APP` bridge
|
||||
## Make the app agent-operable: the `OPENSWARM_APP` bridge
|
||||
|
||||
An agent can drive this app on the user's behalf (e.g. "graph y=x^2 on my
|
||||
Desmos app"). It does NOT do that by clicking pixels or scraping the DOM —
|
||||
that's slow and an app's DOM is often a bare `<canvas>`. Instead, expose a
|
||||
tiny bridge on `window` and the agent reads/acts through it in one fast
|
||||
`executeJavaScript` call. **Always add this bridge to every app you build.**
|
||||
Desmos app"). It does NOT do that by clicking pixels or scraping the DOM (slow,
|
||||
and an app's DOM is often a bare `<canvas>`). Instead it reads and acts through
|
||||
`window.OPENSWARM_APP`, a bridge the template already ships for you
|
||||
(`src/agentBridge.ts`, installed before your app mounts).
|
||||
|
||||
Set `window.OPENSWARM_APP` with three functions:
|
||||
**You do not wire up the bridge; you `register()` into it.** Call
|
||||
`window.OPENSWARM_APP.register({ rules, controls, getState, invoke })` once your
|
||||
app's core object exists (e.g. in a mount `useEffect`). This is REQUIRED for
|
||||
every app: the runtime verifies it and the agent's first action fails loudly
|
||||
with `BRIDGE MISSING` if you forget. Pass:
|
||||
|
||||
- `describe()` → the **current** list of actions, `[{ name, args?, description? }]`.
|
||||
Recompute it live on every call; controls are dynamic, so return only what's
|
||||
actually available right now.
|
||||
- `getState()` → a **small** JSON snapshot of the app's relevant state (used to
|
||||
verify an action landed). Keep it compact — this is the latency budget.
|
||||
- `invoke(name, args)` → perform the named action and return a result (or throw
|
||||
- `rules` (string) - what the app is and its objective, in plain prose. This is
|
||||
what the agent reads to understand the app (e.g. "Flappy Bird. Keep the bird
|
||||
airborne through the pipe gaps; the game ends on a collision.").
|
||||
- `controls` - an array of `{ name, args?, description?, keys? }`, OR a function
|
||||
returning that array when controls are dynamic. `keys` is an optional
|
||||
human-style hint (e.g. `"Space = flap"`). Return only what's available now.
|
||||
- `getState()` - a **small** JSON snapshot used to verify an action landed. Keep
|
||||
it compact; this is the latency budget.
|
||||
- `invoke(name, args)` - perform the named action and return a result (or throw
|
||||
a string the agent will read).
|
||||
|
||||
Keep `args` shapes simple (strings, numbers, booleans, small objects). The agent
|
||||
only ever calls actions that `describe()` returned; it never edits your code.
|
||||
only ever calls actions that `controls` listed; it never edits your code. When
|
||||
dynamic controls change, call `window.OPENSWARM_APP.refresh()` so the agent
|
||||
knows to re-read them (it bumps the `__rev` the agent watches).
|
||||
|
||||
```tsx
|
||||
// Register once the app's core object exists (e.g. after the calculator mounts).
|
||||
// `calc` here is the app's own API (Desmos example); use whatever yours exposes.
|
||||
function registerAgentBridge(calc: any) {
|
||||
(window as any).OPENSWARM_APP = {
|
||||
describe() {
|
||||
const actions = [
|
||||
window.OPENSWARM_APP!.register({
|
||||
rules: 'A graphing calculator. Plot and remove expressions like y=x^2 or y=sin(x).',
|
||||
controls() {
|
||||
const controls = [
|
||||
{ name: 'addExpr', args: { latex: 'string' }, description: 'Add a graph expression, e.g. y=x^2' },
|
||||
{ name: 'clear', description: 'Remove all expressions' },
|
||||
];
|
||||
// Dynamic: only offer removeExpr when something is on the graph.
|
||||
if (calc.getExpressions().length > 0) {
|
||||
actions.push({ name: 'removeExpr', args: { id: 'string' }, description: 'Remove one expression by id' });
|
||||
controls.push({ name: 'removeExpr', args: { id: 'string' }, description: 'Remove one expression by id' });
|
||||
}
|
||||
return actions;
|
||||
return controls;
|
||||
},
|
||||
getState() {
|
||||
return { expressions: calc.getExpressions().map((e: any) => ({ id: e.id, latex: e.latex })) };
|
||||
},
|
||||
invoke(name: string, args: any = {}) {
|
||||
if (name === 'addExpr') { const id = String(Date.now()); calc.setExpression({ id, latex: args.latex }); return { id }; }
|
||||
if (name === 'removeExpr') { calc.removeExpression({ id: args.id }); return { ok: true }; }
|
||||
if (name === 'clear') { calc.setBlank(); return { ok: true }; }
|
||||
if (name === 'addExpr') { const id = String(Date.now()); calc.setExpression({ id, latex: args.latex }); window.OPENSWARM_APP!.refresh(); return { id }; }
|
||||
if (name === 'removeExpr') { calc.removeExpression({ id: args.id }); window.OPENSWARM_APP!.refresh(); return { ok: true }; }
|
||||
if (name === 'clear') { calc.setBlank(); window.OPENSWARM_APP!.refresh(); return { ok: true }; }
|
||||
throw `Unknown action: ${name}`;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
If you skip the bridge the agent falls back to slow UI-driving, so apps meant to
|
||||
be agent-operated should always register it.
|
||||
### Real-time games need a high-level action, not per-frame controls
|
||||
|
||||
An agent acts in discrete tool calls separated by network + model latency
|
||||
(hundreds of ms to seconds). It physically CANNOT hit frame-timing, so exposing
|
||||
only `invoke('flap')` makes a reflex game like Flappy Bird understandable but
|
||||
unwinnable: by the time the agent decides to flap, the bird has already fallen.
|
||||
For anything real-time, expose a **high-level action** the app executes on its
|
||||
own tick loop, e.g. `invoke('autopilot', { on: true })` that runs the optimal
|
||||
input internally, or `invoke('setDifficulty', ...)`. Let the agent set intent;
|
||||
let the app handle the milliseconds. Non-real-time apps (tools, forms, a
|
||||
Spotify-style player) don't need this: their actions are already at agent cadence.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -18,6 +18,32 @@ FRONTEND_DIR_ABSPATH="$(dirname "$RUN_FRONTEND_ABSPATH")"
|
||||
|
||||
cd "$FRONTEND_DIR_ABSPATH"
|
||||
|
||||
# --- Agent bridge check -------------------------------------------------------
|
||||
# Every OpenSwarm app must register window.OPENSWARM_APP so an agent can drive
|
||||
# it. The plumbing ships in src/agentBridge.ts; what an app can still forget is
|
||||
# to CALL register(...) with its own actions. Scan the app source for that call.
|
||||
# Missing -> warn loudly but DON'T block (work-in-progress apps must still load).
|
||||
# The real gate is the runtime check in the app agent; OPENSWARM_REQUIRE_BRIDGE=1
|
||||
# turns this into a hard failure for anyone who wants strict mode.
|
||||
check_agent_bridge() {
|
||||
local src_dir="$FRONTEND_DIR_ABSPATH/src"
|
||||
[ -d "$src_dir" ] || return 0
|
||||
if grep -rEl --include='*.ts' --include='*.tsx' \
|
||||
'OPENSWARM_APP\.register|window\.OPENSWARM_APP[[:space:]]*=' \
|
||||
"$src_dir" 2>/dev/null | grep -qv 'agentBridge\.'; then
|
||||
return 0
|
||||
fi
|
||||
echo ""
|
||||
printf '\033[31m❌ BRIDGE MISSING: window.OPENSWARM_APP not registered - this app is not agent-operable.\033[0m\n'
|
||||
printf '\033[31m Call window.OPENSWARM_APP.register({ rules, controls, getState, invoke }) when your app mounts (see SKILL.md).\033[0m\n'
|
||||
echo ""
|
||||
if [[ "${OPENSWARM_REQUIRE_BRIDGE:-}" == "1" ]]; then
|
||||
echo "OPENSWARM_REQUIRE_BRIDGE=1 set; refusing to start without the agent bridge."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
check_agent_bridge
|
||||
|
||||
# Put the bundled Node on PATH so `npm`, `node`, and the vite child
|
||||
# processes all resolve even on a machine with no system Node. The
|
||||
# packaged Electron shell exports OPENSWARM_NODE_PATH (e.g.
|
||||
|
||||
@@ -68,6 +68,7 @@ _WALK_SKIP_DIRS = frozenset({
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
".openswarm",
|
||||
})
|
||||
|
||||
# Cap per-file response size at 256 KB. Hand-written source rarely
|
||||
|
||||
@@ -146,3 +146,98 @@ def test_selected_app_context_advertises_app_agent(monkeypatch):
|
||||
assert ctx is not None
|
||||
assert "App id (for AppAgent): abc" in ctx
|
||||
assert "AppAgent(output_id, task)" in ctx
|
||||
|
||||
|
||||
# --- bridge readiness + parsing ---------------------------------------------
|
||||
def test_parse_bridge_result_decodes_json_text():
|
||||
assert BA._parse_bridge_result({"text": json.dumps([{"name": "x"}])}) == [{"name": "x"}]
|
||||
assert BA._parse_bridge_result({"text": "null"}) is None
|
||||
assert BA._parse_bridge_result({"error": "boom"}) is None
|
||||
assert BA._parse_bridge_result({"text": "not json"}) is None
|
||||
assert BA._parse_bridge_result({}) is None
|
||||
|
||||
|
||||
def test_bridge_ready_distinguishes_stub_from_registered():
|
||||
assert BA._bridge_ready([{"name": "x"}]) is True # legacy array
|
||||
assert BA._bridge_ready({"controls": [], "__rev": 1}) is True
|
||||
assert BA._bridge_ready({"__ready": False, "__rev": 0}) is False # template stub
|
||||
assert BA._bridge_ready({"__error__": "threw"}) is False
|
||||
assert BA._bridge_ready(None) is False
|
||||
|
||||
|
||||
def test_render_app_controls_array_and_object_forms():
|
||||
# Legacy array form: no rules, controls rendered.
|
||||
rules_md, controls_md = BA._render_app_controls([{"name": "clear", "description": "wipe"}])
|
||||
assert rules_md == ""
|
||||
assert "- `clear`: wipe" in controls_md
|
||||
|
||||
# New object form: rules + keys + args rendered.
|
||||
rules_md, controls_md = BA._render_app_controls({
|
||||
"rules": "Flappy Bird. Keep the bird airborne.",
|
||||
"controls": [{"name": "flap", "keys": "Space = flap", "args": {"force": "number"}, "description": "Flap once"}],
|
||||
"__rev": 3,
|
||||
})
|
||||
assert "Flappy Bird" in rules_md
|
||||
assert "- `flap`" in controls_md
|
||||
assert "[Space = flap]" in controls_md
|
||||
assert '"force"' in controls_md
|
||||
|
||||
# Not-ready / absent bridge yields nothing to render.
|
||||
assert BA._render_app_controls({"__ready": False}) is None
|
||||
assert BA._render_app_controls(None) is None
|
||||
|
||||
|
||||
# --- AppDescribe waits for a still-booting bridge ----------------------------
|
||||
def test_app_describe_polls_until_bridge_ready(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
ready = {"rules": "r", "controls": [{"name": "x"}], "__rev": 1}
|
||||
|
||||
async def _send(request_id, action, browser_id, params, tab_id=""):
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 3:
|
||||
return {"text": json.dumps({"__ready": False, "__rev": 0})}
|
||||
return {"text": json.dumps(ready)}
|
||||
|
||||
async def _no_sleep(_s):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(BA.ws_manager, "send_browser_command", _send, raising=False)
|
||||
monkeypatch.setattr(BA.asyncio, "sleep", _no_sleep, raising=True)
|
||||
monkeypatch.setattr(BA, "_persist_app_controls", lambda *a, **k: None, raising=True)
|
||||
|
||||
out = asyncio.run(BA.execute_browser_tool("AppDescribe", {}, "app:abc"))
|
||||
assert calls["n"] == 3 # polled twice, succeeded on the third
|
||||
assert BA._parse_bridge_result(out) == ready
|
||||
|
||||
|
||||
def test_app_invoke_does_not_poll(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
async def _send(request_id, action, browser_id, params, tab_id=""):
|
||||
calls["n"] += 1
|
||||
return {"text": json.dumps({"__ready": False})} # would loop forever if AppInvoke waited
|
||||
|
||||
async def _no_sleep(_s):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(BA.ws_manager, "send_browser_command", _send, raising=False)
|
||||
monkeypatch.setattr(BA.asyncio, "sleep", _no_sleep, raising=True)
|
||||
|
||||
asyncio.run(BA.execute_browser_tool("AppInvoke", {"name": "flap"}, "app:abc"))
|
||||
assert calls["n"] == 1 # single shot, no readiness wait
|
||||
|
||||
|
||||
def test_app_describe_persists_controls_cache(monkeypatch, tmp_path):
|
||||
ready = {"rules": "Keep the bird airborne.", "controls": [{"name": "flap", "keys": "Space"}], "__rev": 1}
|
||||
|
||||
async def _send(request_id, action, browser_id, params, tab_id=""):
|
||||
return {"text": json.dumps(ready)}
|
||||
|
||||
monkeypatch.setattr(BA.ws_manager, "send_browser_command", _send, raising=False)
|
||||
monkeypatch.setattr(BA, "_app_workspace_dir", lambda bid: str(tmp_path), raising=True)
|
||||
|
||||
asyncio.run(BA.execute_browser_tool("AppDescribe", {}, "app:abc"))
|
||||
controls = (tmp_path / ".openswarm" / "controls.md").read_text()
|
||||
rules = (tmp_path / ".openswarm" / "rules.md").read_text()
|
||||
assert "- `flap`" in controls and "[Space]" in controls
|
||||
assert "Keep the bird airborne." in rules
|
||||
|
||||
Generated
+2
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.2.77",
|
||||
"version": "1.2.79",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openswarm",
|
||||
"version": "1.2.77",
|
||||
"version": "1.2.79",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"electron-updater": "6.8.3",
|
||||
@@ -567,7 +567,6 @@
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -1435,7 +1434,6 @@
|
||||
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.8.1",
|
||||
"builder-util": "26.8.1",
|
||||
@@ -1584,7 +1582,6 @@
|
||||
"integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.8.1",
|
||||
"builder-util": "26.8.1",
|
||||
@@ -2888,7 +2885,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user