diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e66ba82..fcd4c7f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -427,6 +427,15 @@ jobs: | sed 's/\x1b\[[0-9;]*m//g' | tee screen.txt grep -q "? Run \`rm -rf build\`?" screen.txt grep -q "bash ✗" screen.txt + - name: Handoff example (scripted; asks without waiting, the answer starts the next run) + shell: bash + timeout-minutes: 5 + run: | + mkdir -p "$RUNNER_TEMP/handoff-project" && cd "$RUNNER_TEMP/handoff-project" + git init -q && git -c user.email=ci@ci -c user.name=ci commit -q --allow-empty -m start + # The example checks its own outcome and exits non-zero on a regression. + cargo run --manifest-path "$GITHUB_WORKSPACE/$MANIFEST" --example handoff_agent -- --demo | tee out.txt + grep -q "ok: asked without waiting, answered later, wrote notes.md, two reports" out.txt # yoagent claims to be a tested GASP-conformant runtime — this job is the # test: emit an agent repo with the gasp bridge (mock provider, no network) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89b50f74..6b4e0e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,16 @@ adheres to [Semantic Versioning](https://semver.org/). - **One frontend protocol** (`integrations/yoagent-frontend`): a `Session` runs one agent for any number of frontends — a pi-tui terminal UI (a rutis plugin) and browsers over WebSocket — with commands in (prompt, steer, follow-up, abort, reset, quit), and out every event (text deltas merged), a run lifecycle that is always delivered (`runStart` / `runEnd` with an `outcome`: completed, aborted, rejected or error — also when the agent's task or the session's driver fails), plugins' questions (`uiRequest`: confirm, select, input, notify; the first answer that fits wins, a safe default without a frontend or in time; `uiResolved` says why it closed), `notice`s, and `closed`, and **UI plugins**: a rutis plugin offers the browser an ES module (`renderTool` for a tool's results, `mountPanel` for a side panel). `PluginHost` replaces the rutis setup (runtimes, loader, explicit per-row runtime routing; `load` waits until plugins run — though a TypeScript plugin reads as running before its async start-up finishes, so wait for the handlers you need — and drops a batch that fails). The browser's WebSocket needs the token in the URL the server prints. Example `coding_agent` (terminal or `--web`), tried live in Chrome. yoagent's core and yoagent-rutis's API do not depend on it. - **`minimal_browser` example**: one agent and the browser page in about 40 lines, no plugins or Node — the smallest starting point. - **Plugins reload while the agent runs**: `PluginHost::unload(id)`, `reload(id)` (the edited file's new code: a version that does not import is refused and the running one stays; one that fails when it starts leaves the plugin stopped, and the error says so) and `is_running(id)`; the example's `--watch` reloads a plugin when its file changes and tells the frontends, and the example now requires a tool policy, so a stopped policy plugin denies calls instead of letting them through. No rebuild of the host. +- **`handoff_agent` example and `scripts/try-handoff.sh`**: hand it a task and walk away. It plans with the pi extension `@juicesharp/rpiv-todo` (unchanged; drawn as a checklist by the new `todo-plan` UI plugin) and asks without waiting (`ask_owner`). The answer comes back as a steer message, mid-run or starting the next run. Every run ends with a handoff report, plus facts from the host (the change since the handoff, tokens, time, open questions). Tried live with DeepSeek on a phone-sized page. +- **A page opened late shows what happened**: the session keeps its latest runs' messages (up to `HISTORY`, 5000) and replays them to a frontend that connects, after its `Hello`, so a page opened mid-run or afterwards shows the runs, their tool lines, reports and notices. Questions no longer lock the page: they are docked above the input, non-modal. +- **`Session::ask_later`**: a question kept open until answered, even with no frontend attached (one that connects later gets it). It returns `Some(answer)`, or `None` when nobody will answer: withdrawn (a reset withdraws these, since they belong to the forgotten conversation) or the session ended. +- **The example's approval gate** (`confirm-dangerous.ts`, used by `coding_agent` and `handoff_agent`) asks before anything that could destroy or overwrite the owner's files: `rm`, `git reset --hard`, `git clean`, `git checkout --` / `git restore`, `git branch -D`, a forced push, `find -delete`, a write, `mv`/`cp` or `>` over an existing file, and more. Before, it caught only `rm -r`, `sudo`, `chmod -R`, `git push --force`, `mkfs` and `dd`. Files the agent created this session, and the scratch folder a host names in `YOAGENT_SCRATCH`, are exempt. An ended session now ends its open questions with their default, where they used to be left open. Steering or a follow-up that arrives as a run ends (too late for that run to read) now starts the next run instead of waiting for whatever run comes next. - Tool cards: a tool result's `details.view` is drawn as a card in the browser (and titles the tool line: in the browser when no UI plugin draws the tool, always in the terminal UI); questions gain `message` and multiple-choice `select` (options sent once each); the `ui` service gains `frontends()`; the terminal UI asks again on an answer that does not fit. ### yoagent-rutis (unreleased) - **The run's conversation, on request**: a handler can ask for `messages` (yoagent's LLM messages) on the `Turn`, `ToolCall` (`before_tool`) and `Stop` it sees — `Handler::with_messages()` in Rust, `options.messages: true` for TypeScript / Python plugins. Off by default (it copies the conversation for every call); handlers that did not ask never get it. +- pi adapter: what does not map (commands, shortcuts, events yoagent never fires) is reported at `info`, not `warn`: it is expected, not a fault (refusals stay warnings). - **pi adapter: `message_end` and `context` handlers are checked, not refused.** Each message goes (a copy) to the `message_end` handlers once — the assistant message before any of its tools runs — and each request's messages to the `context` handlers; a handler that only reads works, one that would change what it got stops the adapter (yoagent cannot apply the change), and a check cut short by a timeout or cancel does not pass. The final answer is checked after it was given: a rewrite there fails the run only for a required bridge, and refuses later runs. In the [plugin compatibility](docs/evals/plugin-compatibility.md) matrix this takes pi's top 20 from 9 to 12 usable packages (pi-lens, pi-powerline-footer, context-mode; refused 8 → 5). ## yoagent-rutis 0.2.0 (2026-10-10) diff --git a/README.md b/README.md index 5bc2343e..c87aa4c3 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,8 @@ over; commands and UI don't. An extension that hooks something the adapter can't refused, not half-run. How the 20 most-downloaded packages of each ecosystem fare (by monthly downloads, among npm's first 1000 search results per keyword), measured: [plugin compatibility](https://yologdev.github.io/yoagent/evals/plugin-compatibility.html). -`scripts/try-coding-agent.sh` serves a coding agent with pi and DSH plugins for your browser. +`scripts/try-coding-agent.sh` serves a coding agent with pi and DSH plugins for your browser; +`scripts/try-handoff.sh ""` hands a task to an agent that asks without waiting and reports back. ## Runs where agents run diff --git a/integrations/yoagent-frontend/CLAUDE.md b/integrations/yoagent-frontend/CLAUDE.md index f11c5dc7..6964ae13 100644 --- a/integrations/yoagent-frontend/CLAUDE.md +++ b/integrations/yoagent-frontend/CLAUDE.md @@ -6,6 +6,8 @@ Guidance for Claude Code when working in `integrations/yoagent-frontend/`. The r ## Frontend layer (`integrations/yoagent-frontend/`, EXPERIMENTAL, separate crate) -`publish = false`, no API promise; neither yoagent's core nor yoagent-rutis's API depends on it (its one touch outside: the pi adapter's optional dialogs, below). `Session::new(accept_quit) -> (Session, Driver)`: `Driver::run(agent) -> Agent` owns the agent; the `Driver` holds an `Ending` guard, so however it goes (returns, its task panics or is aborted, or it is dropped unrun) an open run gets `RunEnd{outcome: Error}`, the session is marked closed and every frontend gets `Closed`. `Session` (clone; only handles hold the command sender, so dropping them all ends the driver): `connect()` → `Connection { id, messages }` (own unbounded channel per frontend, `Hello` first — then `Closed` at once if the session ended — built under the clients lock, nothing dropped; **lock order is `clients` → `ui`**: never take `clients` (`frontends()`, `broadcast`) while holding `ui`), `send(ClientMessage) -> bool` (false + `warn!` once closed; a `UiResponse` resolves its question only if `UiRequest::accepts` the value — confirm: bool, select: an option or null, input: string or null — else it stays open), `is_closed`, `notice(level, msg)`, crate-private `send_to(id, msg)`, `ask(UiRequest, timeout)` / `ask_keyed(.., key)` (no frontend → `default_answer` at once, logged; else broadcast `UiRequest`, first fitting answer wins; a `Close` guard removes the entry however `ask` ends and broadcasts `UiResolved{id, reason: Answered|TimedOut|Withdrawn}`; a key already in use is not taken over (warned) and the guard removes a key only if it maps to its own id), `withdraw(key)` (unknown key → remembered 60 s, so a withdrawal that overtakes its question returns the default without showing it), `add_ui_plugin(UiPlugin, module) -> Option` (the version; empty name → `None`), `remove_ui_plugin(name, Option)`, `ui_plugin_module`. Protocol (`protocol.rs`, tag `type`, camelCase, enums `#[non_exhaustive]`): in `prompt`/`steer`/`followUp`/`abort`/`reset`/`uiResponse`/`quit`; out `hello{running,uiPlugins,uiRequests}`, `runStart{run,prompt}`, `event{run,event}` (text/thinking deltas merged by `Coalesced`, held ≤30 ms), `runEnd{run,outcome: RunOutcome(completed|aborted|rejected|error),error,stats,totalCostUsd}` (always once per run; no `AgentEnd` → `error` + an error `Notice`; `InputRejected` → `rejected`; Abort/Reset/Quit → `aborted`; else the last assistant message), `uiRequest`/`uiResolved{id,reason}`, `notice{level,message}`, `uiPlugins`, `closed`. `UiPlugin.version` is `skip_deserializing` (set by the session). Prompt mid-run → queued; steer/followUp idle → a prompt; `Reset` mid-run aborts, drops the queue and clears history after `finish`. `services::provide(root, &session)`: host services `frontend` (`connect(client)` sync → `client.receive(msg)` per message in order, raced against the disposer / the caller's `Connection::closed`, a failed `receive` disconnects it; `send` async, `Err` once closed; `addUiPlugin(info, module)` sync → disposer removing that version only, `Err` on an empty name) and `ui` (`frontends()` sync → the attached count; `request(request, timeoutMs?)` → `Value::future`; `key` must be a string, `timeoutMs` null or a finite non-negative number (fractions ok); `withdraw(key)` string only); unit tests drive `invoke` directly. `web::serve(session, addr) -> Served` (private fields; `url()`, `addr()`, `token()`, `stop()`; dropping it stops serving) (axum 0.8, `ws`): `/`, `/app.js`, `/lib.js` (embedded `web/`), `/favicon.ico` 204, `/ui-plugins/{name}` (`.js`), `/ws` **requires `?t=`** (uuid v4; 403 otherwise — WebSockets skip CORS, so without it any page could drive the agent); a frame that is not the protocol → `warn!` + a `Notice` to that frontend; UI plugin modules are trusted code. Page: `web/lib.js` (no DOM; `web/package.json` marks `web/` as ESM — the terminal UI imports it, and rutis's loader would read it as CommonJS: `toolView(details)` (only the six card kinds), `viewTitle`, `diffRows` (LCS; unaligned when old × new line counts exceed 400²), `parseAnswer(request, text)` (the terminal UI's typed answers: `{value}` or `{error}` → asked again, never guessed), `safeUrl` (http(s)), `markdownParts` (https links only), `Questions` FIFO, `runEndLine`), tested by `node --test web/lib.test.js`; `app.js` handles messages serially (a promise chain), refuses to send on a closed socket or ended session (says so; the prompt stays in the box, a dialog stays open). `host::PluginHost::builder().node(name, dir).share(svc).start(root)` → `load(rows)` (`Row::new(id, name).runtime(r).config(..).inject(..)`; a `Routed` resolver sends a row to its named runtime, others to the first that resolves; a name already routed to another runtime is refused) waits until rows are `Active` (reconcile counts `Pending` as settled), errors naming those that are not; a failed batch is dropped (rows and routes restored; a failed rollback is logged); `status()`; `unload(id)` (row dropped, its route too when no other row loads that name, reconcile; restored on failure) and `reload(id)` = `RuntimeResolver::invalidate(name)` on every runtime's resolver (kept in `PluginHost.runtimes`) + `Loader::reload(id)` (re-describe → the runner's `fresh()` imports the changed file with a `?rutis-reload=` query → row replaced) + wait until it runs. **Not atomic:** a module that fails to import/describe is refused before anything stops (old version keeps running), but one that fails in `apply` has already replaced the old row, and the loader's rollback re-imports the same (new) file — the plugin stays `Failed` until a later reload succeeds; the error says "running version stays" or "is stopped" (by `is_running(id)`); the example runs `.require_policy()` so a stopped pi policy denies calls. `load` waits only for the rows it added and fails only on `new_failures`, `unload` waits for none — a stopped plugin does not block other changes. **Without `invalidate`, `Loader::reload` is a no-op** for a Node file row (the resolver caches by package version, and a lone file has none) and `Loader::restart` re-applies the already-imported module. Only the plugin's own module is re-imported (not its imports); a `.ts` plugin must sit in a `"type": "module"` package (tsx loads it as CommonJS otherwise, whose cache ignores the query); pi extensions reload with the pi adapter (pi's jiti loader, `moduleCache: false`). `tests/host_test.rs` (temp ESM `.ts` plugin with a `version` tool: edit → new code, unchanged reload restarts, a syntax error refused with the old code kept, an `apply` that throws leaves it stopped (and another plugin still loads), the next good save brings it back, unload removes the tool). **A TS row is Active before its async `apply` finishes** — wait for the handlers you depend on. **A row-level `inject` on a TS row stops it from ever applying** (don't; re-provide instead, as `host-ui.ts` does). Plugins (`plugins/`, own `package.json`: rutis 0.8.0, pi-tui 1.1.0): `terminal-ui.ts` (pi-tui on `frontend`; `/dev/tty` keyboard; questions answered in the editor, a stale prefill cleared when its question closes; `receive` never throws (shown instead); a failed quit goes to stderr; `demo` headless prints `[header, log, status]` and declines questions), `search-links.ts` (UI plugin: `renderTool` links for search tools, `mountPanel` quick prompts), `pi-extensions/confirm-dangerous.ts` (a pi permission gate using `ctx.ui.select`). pi dialogs: rutis shows a **host** service only to plugins that inject it and starts a plugin only once all injected services exist, so `yoagent-rutis/plugins/pi/host-ui.ts` injects `ui` and re-provides it in the pi runtime as `pi-host-ui`; the adapter looks that up per pi context (`lookupUi`; missing → print mode, said once at debug) → RPC mode (`mode: 'rpc'`, `hasUI: true`, `select`/`confirm`/`input`/`editor`/`notify` forwarded; failures → print-mode answers); each question keyed `pi-` and withdrawn on its signal's abort (a failed withdraw is reported at warn); without it, print mode unchanged. `examples/minimal_browser.rs`: the smallest use — `Session::new(false)` + `web::serve` + an agent with read-only tools (`list_files`, `read_file`, `search`) sandboxed to the cwd (`with_allowed_paths`; the built-ins allow every path by default), no plugins or Node; scripted, or `--live` DeepSeek; tried live in Chrome. `examples/coding_agent.rs`: runtimes `ui`/`pi`/`dsh`, terminal (default) or `--web [ADDR]` (8787), `--demo`, `--live` (`DEEPSEEK_API_KEY`), `--no-dsh`, `--watch` (a task owning the `PluginHost` polls the plugin files every 500 ms by mtime + size — row files, the pi extension → `pi`, terminal-ui in terminal mode — settles 200 ms (files changed meanwhile are reloaded too), `reload`s the owning row and sends every frontend a `Notice` — Info, Warning when the old version stays, Error when the plugin is stopped; tried live in Chrome: an edited pi extension's new question text, a broken save refused); waits for `pi-extensions`/`dsh-tools` handlers; `tracing-subscriber` (stderr with `--web`/`--demo`, else `$TMPDIR/yoagent-coding-agent.log`); a shutdown error never hides the run's. Tool cards: a result's `details.view` (DSH's card vocabulary) is drawn by `app.js` `card()` (terminal/diff/search/read/web/generic; `CARD_LINES` 200 per block, ≤20 web sources; a generic card with no text draws nothing; a card that throws → "(this tool's card could not be drawn)") unless a UI plugin draws that tool; the line takes `viewTitle` (browser: only then; terminal UI: always). `UiRequest::Select { title, message, options, multiple }` (answer: a list of distinct options with `multiple`; a frontend that cannot offer several must leave it unanswered), `Input` has `message` too; `ask_keyed` asks `request.normalized()` (a select's options each once). Tests: `session_test` (19, a `Gate` tool keeps runs in progress), `web_test` (4, tokio-tungstenite), `pi_dialog_test` (real pi runtime: Allow runs `bash`, Block stops it; skipped without `plugins/pi/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`), `dsh_dialog_test` (real DSH runtime: approval yes/no, `ask_user_question`, `details.view`; stopping a run withdraws its open approval (`UiResolved` withdrawn, `RunEnd` aborted, tool not run); skipped without `plugins/dsh/node_modules/@deepseek-ai/dsh-tool-ask-user` — re-run `npm ci` after a lock change), unit tests (`Coalesced`, services, `UiRequest::accepts`); CI job `frontend-experimental` (fmt, clippy, docs, tests with runtimes required, `node --test`, headless example asserting the pi question reached the terminal UI and `bash ✗`). Tried live in Chrome (DeepSeek + DSH search, links rendered by the UI plugin). +`publish = false`, no API promise; neither yoagent's core nor yoagent-rutis's API depends on it (its one touch outside: the pi adapter's optional dialogs, below). `Session::new(accept_quit) -> (Session, Driver)`: `Driver::run(agent) -> Agent` owns the agent; the `Driver` holds an `Ending` guard, so however it goes (returns, its task panics or is aborted, or it is dropped unrun) an open run gets `RunEnd{outcome: Error}`, the session is marked closed and every frontend gets `Closed`. `Session` (clone; only handles hold the command sender, so dropping them all ends the driver): `connect()` → `Connection { id, messages }` (own unbounded channel per frontend, `Hello` first, then the replay of `Inner.history` (the last `HISTORY` = 5000 `runStart`/`event`/`runEnd`/`notice` broadcasts, recorded in `Inner::broadcast` under `clients` then `history`, so a late frontend gets each message once; kept across a reset; questions not included — Hello carries the open ones) — then `Closed` at once if the session ended — built under the clients lock, nothing dropped; **lock order is `clients` → `ui`**: never take `clients` (`frontends()`, `broadcast`) while holding `ui`), `send(ClientMessage) -> bool` (false + `warn!` once closed; a `UiResponse` resolves its question only if `UiRequest::accepts` the value — confirm: bool, select: an option or null, input: string or null — else it stays open), `is_closed`, `notice(level, msg)`, crate-private `send_to(id, msg)`, `ask(UiRequest, timeout)` / `ask_keyed(.., key)` (no frontend → `default_answer` at once, logged; else broadcast `UiRequest`, first fitting answer wins; a `Close` guard removes the entry however `ask` ends and broadcasts `UiResolved{id, reason: Answered|TimedOut|Withdrawn}`; a key already in use is not taken over (warned) and the guard removes a key only if it maps to its own id), `ask_later(request)` (= `ask_inner(.., timeout None, hold true)`: kept open with no frontend attached (later ones get it in `Hello`), no timeout; the `Ending` guard clears `pending` under `clients` → `ui`, and `ask_inner` returns the default when `is_closed()` under the `ui` lock, so a held question never outlives the session), `withdraw(key)` (unknown key → remembered 60 s, so a withdrawal that overtakes its question returns the default without showing it), `add_ui_plugin(UiPlugin, module) -> Option` (the version; empty name → `None`), `remove_ui_plugin(name, Option)`, `ui_plugin_module`. Protocol (`protocol.rs`, tag `type`, camelCase, enums `#[non_exhaustive]`): in `prompt`/`steer`/`followUp`/`abort`/`reset`/`uiResponse`/`quit`; out `hello{running,uiPlugins,uiRequests}`, `runStart{run,prompt}`, `event{run,event}` (text/thinking deltas merged by `Coalesced`, held ≤30 ms), `runEnd{run,outcome: RunOutcome(completed|aborted|rejected|error),error,stats,totalCostUsd}` (always once per run; no `AgentEnd` → `error` + an error `Notice`; `InputRejected` → `rejected`; Abort/Reset/Quit → `aborted`; else the last assistant message), `uiRequest`/`uiResolved{id,reason}`, `notice{level,message}`, `uiPlugins`, `closed`. `UiPlugin.version` is `skip_deserializing` (set by the session). Prompt mid-run → queued; steer/followUp idle → a prompt; steering/follow-ups the loop never read (they arrived after its last look, e.g. while extensions `finish`) become the next run's prompt unless the run was aborted (a reset clears them); `Reset` mid-run aborts, drops the queue and clears history after `finish`. `services::provide(root, &session)`: host services `frontend` (`connect(client)` sync → `client.receive(msg)` per message in order, raced against the disposer / the caller's `Connection::closed`, a failed `receive` disconnects it; `send` async, `Err` once closed; `addUiPlugin(info, module)` sync → disposer removing that version only, `Err` on an empty name) and `ui` (`frontends()` sync → the attached count; `request(request, timeoutMs?)` → `Value::future`; `key` must be a string, `timeoutMs` null or a finite non-negative number (fractions ok); `withdraw(key)` string only); unit tests drive `invoke` directly. `web::serve(session, addr) -> Served` (private fields; `url()`, `addr()`, `token()`, `stop()`; dropping it stops serving) (axum 0.8, `ws`): `/`, `/app.js`, `/lib.js` (embedded `web/`), `/favicon.ico` 204, `/ui-plugins/{name}` (`.js`), `/ws` **requires `?t=`** (uuid v4; 403 otherwise — WebSockets skip CORS, so without it any page could drive the agent); a frame that is not the protocol → `warn!` + a `Notice` to that frontend; UI plugin modules are trusted code. Page: `web/lib.js` (no DOM; `web/package.json` marks `web/` as ESM — the terminal UI imports it, and rutis's loader would read it as CommonJS: `toolView(details)` (only the six card kinds), `viewTitle`, `diffRows` (LCS; unaligned when old × new line counts exceed 400²), `parseAnswer(request, text)` (the terminal UI's typed answers: `{value}` or `{error}` → asked again, never guessed), `safeUrl` (http(s)), `markdownParts` (https links only), `Questions` FIFO, `runEndLine`), tested by `node --test web/lib.test.js`; questions are a docked, non-modal `` (`show()`, fixed above the composer, `body.asking` pads the log, "1 of N questions waiting") — the page stays usable while one waits; `app.js` handles messages serially (a promise chain), refuses to send on a closed socket or ended session (says so; the prompt stays in the box, a dialog stays open). `host::PluginHost::builder().node(name, dir).share(svc).start(root)` → `load(rows)` (`Row::new(id, name).runtime(r).config(..).inject(..)`; a `Routed` resolver sends a row to its named runtime, others to the first that resolves; a name already routed to another runtime is refused) waits until rows are `Active` (reconcile counts `Pending` as settled), errors naming those that are not; a failed batch is dropped (rows and routes restored; a failed rollback is logged); `status()`; `unload(id)` (row dropped, its route too when no other row loads that name, reconcile; restored on failure) and `reload(id)` = `RuntimeResolver::invalidate(name)` on every runtime's resolver (kept in `PluginHost.runtimes`) + `Loader::reload(id)` (re-describe → the runner's `fresh()` imports the changed file with a `?rutis-reload=` query → row replaced) + wait until it runs. **Not atomic:** a module that fails to import/describe is refused before anything stops (old version keeps running), but one that fails in `apply` has already replaced the old row, and the loader's rollback re-imports the same (new) file — the plugin stays `Failed` until a later reload succeeds; the error says "running version stays" or "is stopped" (by `is_running(id)`); the example runs `.require_policy()` so a stopped pi policy denies calls. `load` waits only for the rows it added and fails only on `new_failures`, `unload` waits for none — a stopped plugin does not block other changes. **Without `invalidate`, `Loader::reload` is a no-op** for a Node file row (the resolver caches by package version, and a lone file has none) and `Loader::restart` re-applies the already-imported module. Only the plugin's own module is re-imported (not its imports); a `.ts` plugin must sit in a `"type": "module"` package (tsx loads it as CommonJS otherwise, whose cache ignores the query); pi extensions reload with the pi adapter (pi's jiti loader, `moduleCache: false`). `tests/host_test.rs` (temp ESM `.ts` plugin with a `version` tool: edit → new code, unchanged reload restarts, a syntax error refused with the old code kept, an `apply` that throws leaves it stopped (and another plugin still loads), the next good save brings it back, unload removes the tool). **A TS row is Active before its async `apply` finishes** — wait for the handlers you depend on. **A row-level `inject` on a TS row stops it from ever applying** (don't; re-provide instead, as `host-ui.ts` does). Plugins (`plugins/`, own `package.json`: rutis 0.8.0, pi-tui 1.1.0): `terminal-ui.ts` (pi-tui on `frontend`; `/dev/tty` keyboard; questions answered in the editor, a stale prefill cleared when its question closes; `receive` never throws (shown instead); a failed quit goes to stderr; `demo` headless prints `[header, log, status]` and declines questions), `search-links.ts` (UI plugin: `renderTool` links for search tools, `mountPanel` quick prompts), `pi-extensions/confirm-dangerous.ts` (a pi permission gate using `ctx.ui.select`). pi dialogs: rutis shows a **host** service only to plugins that inject it and starts a plugin only once all injected services exist, so `yoagent-rutis/plugins/pi/host-ui.ts` injects `ui` and re-provides it in the pi runtime as `pi-host-ui`; the adapter looks that up per pi context (`lookupUi`; missing → print mode, said once at debug) → RPC mode (`mode: 'rpc'`, `hasUI: true`, `select`/`confirm`/`input`/`editor`/`notify` forwarded; failures → print-mode answers); each question keyed `pi-` and withdrawn on its signal's abort (a failed withdraw is reported at warn); without it, print mode unchanged. `examples/minimal_browser.rs`: the smallest use — `Session::new(false)` + `web::serve` + an agent with read-only tools (`list_files`, `read_file`, `search`) sandboxed to the cwd (`with_allowed_paths`; the built-ins allow every path by default), no plugins or Node; scripted, or `--live` DeepSeek; tried live in Chrome. `examples/coding_agent.rs`: runtimes `ui`/`pi`/`dsh`, terminal (default) or `--web [ADDR]` (8787), `--demo`, `--live` (`DEEPSEEK_API_KEY`), `--no-dsh`, `--watch` (a task owning the `PluginHost` polls the plugin files every 500 ms by mtime + size — row files, the pi extension → `pi`, terminal-ui in terminal mode — settles 200 ms (files changed meanwhile are reloaded too), `reload`s the owning row and sends every frontend a `Notice` — Info, Warning when the old version stays, Error when the plugin is stopped; tried live in Chrome: an edited pi extension's new question text, a broken save refused); waits for `pi-extensions`/`dsh-tools` handlers; `tracing-subscriber` (stderr with `--web`/`--demo`, else `$TMPDIR/yoagent-coding-agent.log`); a shutdown error never hides the run's. Tool cards: a result's `details.view` (DSH's card vocabulary) is drawn by `app.js` `card()` (terminal/diff/search/read/web/generic; `CARD_LINES` 200 per block, ≤20 web sources; a generic card with no text draws nothing; a card that throws → "(this tool's card could not be drawn)") unless a UI plugin draws that tool; the line takes `viewTitle` (browser: only then; terminal UI: always). `UiRequest::Select { title, message, options, multiple }` (answer: a list of distinct options with `multiple`; a frontend that cannot offer several must leave it unanswered), `Input` has `message` too; `ask_keyed` asks `request.normalized()` (a select's options each once). Tests: `session_test` (19, a `Gate` tool keeps runs in progress), `web_test` (4, tokio-tungstenite), `pi_dialog_test` (real pi runtime: Allow runs `bash`, Block stops it; skipped without `plugins/pi/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`), `dsh_dialog_test` (real DSH runtime: approval yes/no, `ask_user_question`, `details.view`; stopping a run withdraws its open approval (`UiResolved` withdrawn, `RunEnd` aborted, tool not run); skipped without `plugins/dsh/node_modules/@deepseek-ai/dsh-tool-ask-user` — re-run `npm ci` after a lock change), unit tests (`Coalesced`, services, `UiRequest::accepts`); CI job `frontend-experimental` (fmt, clippy, docs, tests with runtimes required, `node --test`, headless example asserting the pi question reached the terminal UI and `bash ✗`). Tried live in Chrome (DeepSeek + DSH search, links rendered by the UI plugin). `scripts/try-coding-agent.sh` (repo root): checks cargo / Node 24+ / npm, `npm ci` in the three plugin dirs when `node_modules/.package-lock.json` is missing or older than the lockfile, then runs `coding_agent` with `--web` unless `--terminal` (or `--web`/`--demo` given); `--setup-only`; `--live` needs `DEEPSEEK_API_KEY`; empty-array expansions are bash-3.2 safe. + +`examples/handoff_agent.rs` + `scripts/try-handoff.sh` (runs in the caller's cwd; `try-coding-agent.sh --setup-only` first): pi rows (adapter with `plugins/node_modules/@juicesharp/rpiv-todo/index.ts` + `confirm-dangerous.ts`; `plugins/package.json` pins `@juicesharp/rpiv-todo`/`rpiv-i18n` 2.12.0, `plugins/.npmrc` `legacy-peer-deps=true` — `npm ci` refuses the lock otherwise: pi's packages come from the adapter, never installed there), `pi-host-ui`, `todo-plan` UI plugin (`renderTool` for `todo` from `details.tasks`), DSH search (no `ask_user_question`: blocking). `AskOwner` tool (Select with ≥2 `options`, else Input; returns at once; a spawned task awaits `ask_later` → `Some(answer)` → `ClientMessage::Steer` `[Owner's answer to #N] …` / `[Owner skipped #N] …`; `None` (withdrawn by a reset — `Inner::withdraw_held` drops `UiState.held` questions — or the session ended) → nothing). `Start { head, tree }`: `tree` = `git stash create` (a commit of the uncommitted tracked edits; files, index and stash list untouched) else `head`; the diff is against `tree`, commits counted from `head`. The scratch folder is made in `main` and exported as `YOAGENT_SCRATCH` before the runtime starts; `confirm-dangerous.ts` exempts it and files the agent created this session (paths compared via `realpath`), and asks for `rm`/`unlink`/`rmdir` (globs always), `mv`/`cp`/`>` onto an existing file, writes over an existing file, `git reset --hard`/`clean`/`checkout --`/`restore`/`branch -D`/forced push, `find -delete`, `truncate`/`shred`/`mkfs`/`dd`/`sudo`/`ch{mod,own} -R`, `.env`/`.git/`. The scripted demo does what its reports say (run 2 writes `notes.md`; checked, with the facts naming it). `Handoff` extension: `on_stop` requires the four `REPORT` headings (Continue otherwise), `on_event` counts tokens/tool calls, `finish` → `Notice` facts (diff/commits vs the HEAD at launch, untracked files new since launch (named, ≤3), tokens, tool calls, time, open questions). Limits 80 turns / `--max-tokens` (4M: research reads a lot; yoagent's 1M default counts the whole prompt each turn) / `--max-minutes`; a `RunEnd::Stopped` run gets a Warning notice ("Stopped early: … send continue"); scratch goes to `$TMPDIR/yoagent-handoff-` (named in the prompt: only deliverables in the cwd; search tools, not whole downloaded pages; write the deliverable early); a repo with no commit compares against git's empty tree (`EMPTY_TREE`); `Budget::for_model(..).across_runs()` only when priced (`prices::enable_bundled()`; DeepSeek unpriced). `with_tools` replaces the list (one vec of `default_tools()` + `AskOwner`). `--demo`: `Session::new(true)` (tools hold session handles, so it ends with `Quit`), a connected frontend answers #1 after run 1 → run 2; self-checks; in CI (`frontend-experimental`). Tried live (DeepSeek, phone-sized Chrome, a scratch Python repo). diff --git a/integrations/yoagent-frontend/README.md b/integrations/yoagent-frontend/README.md index 3a96fe94..627679ed 100644 --- a/integrations/yoagent-frontend/README.md +++ b/integrations/yoagent-frontend/README.md @@ -178,6 +178,65 @@ results rendered as links by the UI plugin, pi's question answered in the page. Logs (`RUST_LOG`) go to stderr with `--web` / `--demo`, else to `$TMPDIR/yoagent-coding-agent.log`, since the terminal UI owns the screen. +### `handoff_agent`: hand it a task and walk away + +A personal handoff agent for any task: research, writing, planning or code. +It works in the directory you start it from, and puts what it makes there: + +```bash +mkdir research && cd research && git init +path/to/yoagent/scripts/try-handoff.sh --live "Research the best noise-cancelling headphones right now; write headphones.md with sources. Ask me my budget, but don't wait." +path/to/yoagent/scripts/try-handoff.sh --phone --live "…" # serve on the LAN, for a phone +cargo run --example handoff_agent -- --demo # headless, scripted (CI): asks, gets an answer, writes notes.md +``` + +- **It plans** with a pi extension used unchanged, `@juicesharp/rpiv-todo`. + The `todo-plan` UI plugin draws the plan as a checklist in the page. +- **It researches** with DSH's free web search, and works with yoagent's + file and shell tools. +- **It asks without waiting.** `ask_owner` puts a question to every + frontend and returns at once, so the agent carries on with what does not + depend on it. The question stays open while nobody is connected + (`Session::ask_later`), and a phone that connects later gets it. The + answer comes back as a steer message: picked up mid-run, or starting a new + run if the agent had stopped. A dismissed question comes back as + "skipped", and the agent decides itself and says so. A reset withdraws + open questions: they belong to the conversation it forgot. +- **Guardrails:** + - A pi extension (`confirm-dangerous.ts`) asks before anything that could + destroy or overwrite what you already have: `rm`, `git reset --hard`, + `git clean`, a forced push, a write or `>` over an existing file, and + more. Files the agent created this session, and its own scratch + folder, are exempt. These questions do wait, and with nobody + connected they are refused. + - Every run needs that policy (`require_policy`). + - Runs are capped in turns and minutes (`--max-minutes`), and in dollars + (`--budget-usd`) when the model has a price. DeepSeek has none in the + bundled table. +- **Every run ends with a handoff report** (Done / Waiting on you / + Decisions / Next). The agent is sent back until it writes one. The host + then adds facts the model cannot make up: the change since the handoff + started (against your working tree as it was, so edits you had not + committed are not counted), commits, new files, tokens, tool calls, + time, and the questions still open. + +Tried live with DeepSeek and a phone-sized Chrome on a research task: +"the best noise-cancelling headphones, ask my budget". It asked about +budget and comfort vs sound, and kept searching while they waited. It +folded in the answers as they came, and wrote a 164-line `headphones.md` +with 33 sources and a pick matched to the answers. It also named the one +review site it could not read. + +And on a small Python project ("move the config from JSON to TOML"). The agent started before any +browser was attached. +- **Run 1:** it planned, migrated, verified and committed. It asked two + questions along the way without stopping, and reported them open. +- **The first answer** ("delete config.json") started run 2, which did that. +- **The second question, dismissed,** started run 3: the agent decided + itself and wrote down why. +- **While nobody was there,** `rm -rf __pycache__` was refused, and the + agent worked around it. + ## Tests ```bash diff --git a/integrations/yoagent-frontend/examples/handoff_agent.rs b/integrations/yoagent-frontend/examples/handoff_agent.rs new file mode 100644 index 00000000..c4cd8c2f --- /dev/null +++ b/integrations/yoagent-frontend/examples/handoff_agent.rs @@ -0,0 +1,836 @@ +//! EXPERIMENTAL. A personal handoff agent: hand it a task — research, +//! writing, planning or code — and walk away. +//! +//! - It plans with a pi extension, unchanged (`@juicesharp/rpiv-todo`'s +//! `todo` tool; the `todo-plan` UI plugin draws the plan in the browser), +//! researches with DSH's free web search, and works with yoagent's file +//! and shell tools in the directory it was started in. +//! - It asks without waiting: `ask_owner` puts a question to every frontend +//! — kept open while nobody is there (`Session::ask_later`) — and returns +//! at once, so the agent carries on with what does not depend on it. The +//! answer, whenever it comes, is sent in as a steer message: picked up by +//! the run in progress, or starting a new run when the agent had stopped. +//! - Guardrails: a pi extension asks before dangerous commands (those do +//! wait); every run needs that policy (`require_policy`); runs are capped +//! in turns and minutes, and in dollars when the model has a price. +//! - Every run ends with a handoff report (Done / Waiting on you / +//! Decisions / Next — the agent is sent back until it writes one), and +//! the host adds what the model cannot make up: the files changed, the +//! tokens, tool calls and time, the questions still open. +//! +//! ```bash +//! scripts/try-handoff.sh "migrate the config to TOML, keep tests green" # from the repo root +//! cargo run --example handoff_agent -- --live "…" # DeepSeek (DEEPSEEK_API_KEY) +//! cargo run --example handoff_agent -- --phone "…" # serve on the LAN, for a phone +//! cargo run --example handoff_agent -- --demo # headless, scripted (CI) +//! ``` +//! +//! `--web ADDR` (default 127.0.0.1:8787), `--no-dsh`, `--max-minutes N` +//! (30) and `--max-tokens N` (4M) per run, `--budget-usd X` (2; priced +//! models only). Without a task, give it +//! one in the page. `--phone` serves on every interface: anyone on the +//! network with the printed URL (it carries the token) drives the agent. +//! +//! Setup: `scripts/try-coding-agent.sh --setup-only` (Node 24+, npm). + +use std::collections::HashSet; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use rutis::Ctx; +use serde_json::{json, Value as Json}; +use yoagent::context::ExecutionLimits; +use yoagent::extension::{ + Extension, ExtensionError, RunContext, RunHooks, RunOutcome, StopContext, StopDecision, +}; +use yoagent::provider::mock::{MockResponse, MockToolCall}; +use yoagent::provider::{prices, MockProvider, ModelConfig}; +use yoagent::tools::default_tools; +use yoagent::{ + Agent, AgentEvent, AgentMessage, AgentTool, Content, Message, ToolContext, ToolError, + ToolResult, +}; +use yoagent_frontend::host::{PluginHost, Row}; +use yoagent_frontend::{ + services, session, web, ClientMessage, NoticeLevel, ServerMessage, Session, UiRequest, +}; +use yoagent_rutis::RutisBridge; + +type BoxError = Box; + +/// Where a handoff started, in git. +#[derive(Clone)] +struct Start { + /// The commit (git's empty tree when the repository has none yet): + /// commits after it are the agent's. + head: String, + /// The working tree, uncommitted edits included (`git stash create` + /// stores them as a commit without touching the files, the index or + /// the stash list), else `head`: changes since it are the agent's. + tree: String, +} + +impl Start { + /// `None` outside a git repository. + fn of(cwd: &std::path::Path) -> Option { + git(cwd, &["rev-parse", "--git-dir"])?; + let head = git(cwd, &["rev-parse", "HEAD"]).unwrap_or_else(|| EMPTY_TREE.to_owned()); + let tree = git(cwd, &["stash", "create"]) + .filter(|snapshot| !snapshot.is_empty()) + .unwrap_or_else(|| head.clone()); + Some(Start { head, tree }) + } +} + +/// Git's empty tree: the base of a repository with no commit yet. +const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + +/// The sections every run's last message must have. +const REPORT: [&str; 4] = ["Done", "Waiting on you", "Decisions", "Next"]; + +fn here() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} +fn rutis_plugins() -> PathBuf { + here().join("../yoagent-rutis/plugins") +} + +struct Options { + key: Option, + addr: SocketAddr, + phone: bool, + demo: bool, + dsh: bool, + task: Option, + max_minutes: u64, + max_tokens: usize, + budget_usd: f64, + /// The agent's own scratch (downloads, helper scripts): not the owner's folder. + scratch: PathBuf, +} + +fn main() -> Result<(), BoxError> { + let mut options = Options { + key: None, + addr: "127.0.0.1:8787".parse()?, + phone: false, + demo: false, + dsh: rutis_plugins().join("dsh/node_modules").exists(), + task: None, + max_minutes: 30, + // Research reads a lot: yoagent's default (1M per run, the whole + // prompt counted each turn) stops a long one before it writes up. + max_tokens: 4_000_000, + budget_usd: 2.0, + scratch: std::env::temp_dir().join(format!("yoagent-handoff-{}", std::process::id())), + }; + let mut live = false; + let mut words = Vec::new(); + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--live" => live = true, + "--demo" => options.demo = true, + "--no-dsh" => options.dsh = false, + "--phone" => { + options.phone = true; + options.addr = "0.0.0.0:8787".parse()?; + } + "--web" => options.addr = args.next().ok_or("--web needs ADDR")?.parse()?, + "--max-minutes" => { + options.max_minutes = args.next().ok_or("--max-minutes needs N")?.parse()? + } + "--max-tokens" => { + options.max_tokens = args.next().ok_or("--max-tokens needs N")?.parse()? + } + "--budget-usd" => { + options.budget_usd = args.next().ok_or("--budget-usd needs X")?.parse()? + } + flag if flag.starts_with("--") => return Err(format!("unknown argument {flag}").into()), + word => words.push(word.to_owned()), + } + } + options.task = (!words.is_empty()).then(|| words.join(" ")); + for dir in [here().join("plugins"), rutis_plugins().join("pi")] { + if !dir.join("node_modules").exists() { + return Err(format!( + "run `npm ci` in {} first (or scripts/try-coding-agent.sh --setup-only)", + dir.display() + ) + .into()); + } + } + if live { + match std::env::var("DEEPSEEK_API_KEY") { + Ok(key) if !key.trim().is_empty() => options.key = Some(key.trim().to_owned()), + _ => return Err("--live needs DEEPSEEK_API_KEY".into()), + } + } + std::fs::create_dir_all(&options.scratch)?; + // Before any thread starts: the plugin runtimes inherit it. The approval + // gate (confirm-dangerous.ts) lets the agent delete and overwrite freely + // there, and nowhere else it did not create. + std::env::set_var("YOAGENT_SCRATCH", &options.scratch); + if std::env::var_os("NODE_OPTIONS").is_none() { + std::env::set_var("NODE_OPTIONS", "--no-warnings"); + } + let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { + tracing_subscriber::EnvFilter::new("warn,yoagent::provider::registry=error") + }); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .init(); + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(async { + let root = Ctx::root()?; + let result = run(&root, options).await; + tokio::time::sleep(Duration::from_millis(100)).await; + if let Err(e) = root.shutdown().await { + match &result { + Ok(()) => return Err(e.into()), + Err(_) => eprintln!("shutting the plugins down also failed: {e}"), + } + } + result + }) +} + +async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { + let cwd = std::env::current_dir()?; + let scratch = options.scratch.clone(); + // The demo ends itself with `Quit`: the agent's own tools hold session + // handles, so the driver would not end when the demo drops its own. + let (session, driver) = Session::new(options.demo); + let bridge = RutisBridge::install(root)?; + services::provide(root, &session)?; + + let mut builder = PluginHost::builder() + .node("ui", here().join("plugins")) + .node("pi", rutis_plugins().join("pi")); + if options.dsh { + builder = builder.node("dsh", rutis_plugins().join("dsh")); + } + let mut host = builder + .share("yoagent") + .share(services::FRONTEND) + .share(services::UI) + .start(root) + .await?; + let mut rows = vec![ + // pi extensions, unchanged: the plan, and approval of dangerous commands. + Row::new( + "pi", + rutis_plugins() + .join("pi/pi-extensions-adapter.ts") + .display(), + ) + .runtime("pi") + .config(json!({ + "extensions": [ + here().join("plugins/node_modules/@juicesharp/rpiv-todo/index.ts"), + here().join("plugins/pi-extensions/confirm-dangerous.ts"), + ], + "cwd": cwd, + })), + Row::new( + "pi-host-ui", + rutis_plugins().join("pi/host-ui.ts").display(), + ) + .runtime("pi"), + Row::new("todo-plan", here().join("plugins/todo-plan.ts").display()).runtime("ui"), + ]; + if options.dsh { + let dsh = |id: &str, name: &str| Row::new(id, name).runtime("dsh"); + rows.extend([ + dsh("dsh-web", "@deepseek-ai/dsh-web"), + dsh("dsh-system-prompt", "@deepseek-ai/dsh-system-prompt"), + dsh("dsh-tools", "@deepseek-ai/dsh-tools"), + dsh("dsh-free-search", "dsh-free-search").config( + json!({ "provider": "bing", "disabledEngines": [], "bingMarket": "en-US" }), + ), + Row::new( + "dsh", + rutis_plugins().join("dsh/dsh-tools-adapter.ts").display(), + ) + .runtime("dsh"), + Row::new( + "dsh-host-dialogs", + rutis_plugins().join("dsh/host-dialogs.ts").display(), + ) + .runtime("dsh"), + ]); + } + host.load(rows).await?; + // A TypeScript row runs before its async start-up is done: wait for the + // handlers the agent depends on, the pi policy above all. + let mut needed = vec!["pi-extensions"]; + if options.dsh { + needed.push("dsh-tools"); + } + let registry = bridge.registry().clone(); + tokio::time::timeout(Duration::from_secs(60), async { + while !needed + .iter() + .all(|name| registry.handlers().iter().any(|h| h.name() == *name)) + { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .map_err(|_| { + format!( + "plugins did not register their handlers: {:?}", + host.status() + ) + })?; + + // Prices are opt-in; a model with none gets no dollar cap (turns and minutes still apply). + prices::enable_bundled(); + let config = match &options.key { + Some(_) => ModelConfig::deepseek("deepseek-flash", "DeepSeek Flash"), + None => ModelConfig::mock(), + }; + let budget = yoagent::extension::Budget::for_model(options.budget_usd, &config) + .map(|budget| budget.across_runs()); + let open = Arc::new(AtomicUsize::new(0)); + let mut agent = match &options.key { + Some(key) => Agent::from_config(config).with_api_key(key.clone()), + None => Agent::from_provider(scripted(), config), + } + .with_system_prompt(system_prompt(&cwd, &scratch)) + .with_tools({ + // `with_tools` sets the whole list: yoagent's tools and `ask_owner`. + let mut tools = default_tools(); + tools.push(Box::new(AskOwner { + session: session.clone(), + next: Arc::default(), + open: open.clone(), + })); + tools + }) + .with_execution_limits( + ExecutionLimits::default() + .with_max_turns(80) + .with_max_total_tokens(options.max_tokens) + .with_max_duration(Duration::from_secs(options.max_minutes * 60)), + ) + // Questions from pi's approval wait up to the frontends' timeout. + .with_extension( + bridge + .extension() + .require_policy() + .with_policy_timeout(Some(session::UI_TIMEOUT + Duration::from_secs(30))), + ) + .with_extension(Handoff { + session: session.clone(), + start: Start::of(&cwd), + // Untracked files already there are not the agent's. + untracked: untracked(&cwd), + cwd: cwd.clone(), + open, + }); + let priced = budget.is_some(); + if let Some(budget) = budget { + agent = agent.with_extension(budget); + } + + if options.demo { + return demo(session, driver, agent, options.task).await; + } + let served = web::serve(session.clone(), options.addr).await?; + let url = match (options.phone, lan_ip()) { + (true, Some(ip)) => format!("http://{ip}:{}/?t={}", served.addr().port(), served.token()), + _ => served.url(), + }; + println!("handoff agent: {url}"); + println!( + " limits: {} min per run{} (Ctrl+C to stop)", + options.max_minutes, + if priced { + format!(", ${:.2} in all", options.budget_usd) + } else { + ", no dollar cap (this model has no price)".into() + } + ); + if let Some(task) = options.task { + session.send(ClientMessage::Prompt { text: task }); + } + tokio::select! { + _ = driver.run(agent) => {} + stop = tokio::signal::ctrl_c() => { + if let Err(e) = stop { + eprintln!("cannot listen for Ctrl+C ({e}); stopping"); + } + } + } + served.stop(); + Ok(()) +} + +fn system_prompt(cwd: &std::path::Path, scratch: &std::path::Path) -> String { + format!( + "You are the owner's handoff agent. The owner handed you a task — research, writing, \ + planning, code, whatever it is — and went away; you finish it on your own. You can \ + search the web, and read, write and run things in {cwd}.\n\ + - Plan with the `todo` tool: create the steps first, mark each in progress and done.\n\ + - Put what you make in files in {cwd} (notes, reports, plans, code) so the owner finds \ + it there, and only that: keep your scratch (downloaded pages, helper scripts, notes to \ + yourself) in {scratch}. Write the deliverable early and update it as you go, so \ + stopping early still leaves something useful.\n\ + - For research, use the search tools (they return extracted text) rather than \ + downloading whole pages; never print a whole page. Cite your sources with links and \ + say how sure you are.\n\ + - Decide what you can find out or reasonably choose yourself. Ask with `ask_owner` only \ + what is the owner's to decide: preferences, budgets, taste, trade-offs only they can \ + weigh. Never wait for an answer: keep going with work that does not depend on it. It \ + arrives later as a message starting with `[Owner's answer to #N]` (or `[Owner \ + skipped #N]`: then decide yourself and say so). If everything left depends on an \ + answer, stop.\n\ + - Do nothing that cannot be undone or that acts for the owner (deleting their files, \ + spending money, sending messages, signing up for things) without their answer first. \ + Some commands ask the owner for approval and may be refused.\n\ + - End every run with a handoff report in Markdown, with these sections: {sections}. \ + Under Waiting on you, list the open questions by number.", + cwd = cwd.display(), + scratch = scratch.display(), + sections = REPORT.map(|s| format!("## {s}")).join(", "), + ) +} + +/// Puts a question to the owner and returns at once; the answer comes back +/// as a steer message. +struct AskOwner { + session: Session, + next: Arc, + /// Questions not answered yet (shared with the report). + open: Arc, +} + +#[async_trait::async_trait] +impl AgentTool for AskOwner { + fn name(&self) -> &str { + "ask_owner" + } + fn label(&self) -> &str { + "Ask the owner" + } + fn description(&self) -> &str { + "Ask the owner a question they decide, without waiting: this returns at once. Continue \ + with work that does not depend on the answer; it arrives later as a message. Give \ + `options` when the answer is one of a few choices." + } + fn parameters_schema(&self) -> Json { + json!({ + "type": "object", + "properties": { + "question": { "type": "string", "description": "The question, on its own." }, + "why": { "type": "string", "description": "What depends on it, briefly." }, + "options": { "type": "array", "items": { "type": "string" } } + }, + "required": ["question"] + }) + } + + async fn execute(&self, params: Json, _ctx: ToolContext) -> Result { + let question = params["question"] + .as_str() + .filter(|q| !q.trim().is_empty()) + .ok_or_else(|| ToolError::InvalidArgs("`question` is required".into()))? + .to_owned(); + let why = params["why"].as_str().unwrap_or_default().to_owned(); + let options: Vec = params["options"] + .as_array() + .map(|o| { + o.iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(); + let n = self.next.fetch_add(1, Ordering::Relaxed) + 1; + let title = format!("#{n} {question}"); + let request = if options.len() >= 2 { + UiRequest::Select { + title, + message: why, + options, + multiple: false, + } + } else { + UiRequest::Input { + title, + message: why, + placeholder: "Your answer".into(), + value: String::new(), + } + }; + let (session, open) = (self.session.clone(), self.open.clone()); + open.fetch_add(1, Ordering::Relaxed); + tokio::spawn(async move { + let answer = session.ask_later(request).await; + open.fetch_sub(1, Ordering::Relaxed); + // Withdrawn (a reset forgot the conversation it belongs to) or the + // session ended: nothing to tell the agent. + let Some(answer) = answer else { return }; + let text = match answer.as_str().map(str::trim) { + Some(answer) if !answer.is_empty() => { + format!("[Owner's answer to #{n}] {question}\n{answer}") + } + _ => format!("[Owner skipped #{n}] {question}"), + }; + // Mid-run it is picked up between tool batches; when the agent + // had stopped, it starts a new run. + session.send(ClientMessage::Steer { text }); + }); + Ok(ToolResult { + content: vec![Content::Text { + text: format!( + "Asked the owner as #{n}. Do not wait: continue with what does not depend on \ + it. The answer will arrive as a message starting with [Owner's answer to #{n}]." + ), + }], + details: json!({ "question": n }), + }) + } +} + +/// Every run ends with the handoff report, and the facts the model cannot +/// make up, told to the frontends. +struct Handoff { + session: Session, + cwd: PathBuf, + /// Where the handoff started (`None`: not a git repository). + start: Option, + /// The untracked files when the handoff started. + untracked: Arc>, + open: Arc, +} + +#[async_trait::async_trait] +impl Extension for Handoff { + fn name(&self) -> &str { + "handoff" + } + async fn start_run(&self, _run: &RunContext<'_>) -> Result, ExtensionError> { + Ok(Box::new(HandoffRun { + session: self.session.clone(), + cwd: self.cwd.clone(), + start: self.start.clone(), + untracked: self.untracked.clone(), + open: self.open.clone(), + started: Instant::now(), + counts: Mutex::default(), + })) + } +} + +struct HandoffRun { + session: Session, + cwd: PathBuf, + start: Option, + untracked: Arc>, + open: Arc, + started: Instant, + /// Tokens in and out, tool calls. + counts: Mutex<(u64, u64, usize)>, +} + +#[async_trait::async_trait] +impl RunHooks for HandoffRun { + fn on_event(&self, event: &AgentEvent) { + let mut counts = self.counts.lock().unwrap(); + match event { + AgentEvent::MessageEnd { + message: AgentMessage::Llm(Message::Assistant { usage, .. }), + } => { + counts.0 += usage.input + usage.cache_read; + counts.1 += usage.output; + } + AgentEvent::ToolExecutionEnd { .. } => counts.2 += 1, + _ => {} + } + } + + async fn on_stop(&mut self, stop: &StopContext<'_>) -> StopDecision { + let Message::Assistant { content, .. } = stop.answer else { + return StopDecision::Accept; + }; + let text: String = content + .iter() + .filter_map(|c| match c { + Content::Text { text } => Some(text.to_lowercase()), + _ => None, + }) + .collect(); + let missing: Vec<&str> = REPORT + .into_iter() + .filter(|s| !text.contains(&s.to_lowercase())) + .collect(); + if missing.is_empty() { + StopDecision::Accept + } else { + StopDecision::Continue(format!( + "End with the handoff report: sections {}. Missing: {}.", + REPORT.map(|s| format!("## {s}")).join(", "), + missing.join(", ") + )) + } + } + + async fn finish(&mut self, outcome: &RunOutcome) { + // A run a limit stopped has no report: say so, and how to go on. + if let yoagent::extension::RunEnd::Stopped { reason } = outcome.end() { + self.session.notice( + NoticeLevel::Warning, + format!("Stopped early: {reason}. Send \"continue\" to pick up where it left off."), + ); + } + let (tokens_in, tokens_out, tools) = *self.counts.lock().unwrap(); + // Since the handoff started: committed and uncommitted changes alike. + let changed = match &self.start { + Some(start) => { + // Against the working tree as it was: edits the owner had not + // committed are not the agent's. + let diff = + git(&self.cwd, &["diff", "--shortstat", &start.tree]).unwrap_or_default(); + let range = if start.head == EMPTY_TREE { + "HEAD".to_owned() + } else { + format!("{}..HEAD", start.head) + }; + let commits = git(&self.cwd, &["rev-list", "--count", &range]) + .and_then(|n| n.parse::().ok()) + .unwrap_or(0); + let diff = if diff.is_empty() { + "no changes".into() + } else { + diff + }; + format!("since the handoff: {diff}, {commits} commit(s)") + } + None => "not a git repository".into(), + }; + // New since the handoff started, named (a test run's bytecode shows as what it is). + let mut new: Vec = untracked(&self.cwd) + .iter() + .filter(|f| !self.untracked.contains(*f)) + .cloned() + .collect(); + new.sort(); + let new = match new.len() { + 0 => String::new(), + n if n <= 3 => format!(", new: {}", new.join(", ")), + n => format!(", {n} new files: {}, …", new[..3].join(", ")), + }; + let open = self.open.load(Ordering::Relaxed); + self.session.notice( + NoticeLevel::Info, + format!( + "Handoff facts: {changed}{}; {tokens_in} tokens in, {tokens_out} out; {tools} tool \ + call(s); {:.0}s; {open} question(s) waiting on you.", + new, + self.started.elapsed().as_secs_f64(), + ), + ); + } +} + +/// The untracked files (git's ignore rules apply); empty outside a repository. +fn untracked(cwd: &std::path::Path) -> Arc> { + Arc::new( + git(cwd, &["ls-files", "--others", "--exclude-standard"]) + .map(|s| s.lines().map(str::to_owned).collect()) + .unwrap_or_default(), + ) +} + +fn git(cwd: &std::path::Path, args: &[&str]) -> Option { + let out = std::process::Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()) +} + +/// This machine's address on the local network (no packet is sent). +fn lan_ip() -> Option { + let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("192.0.2.1:9").ok()?; + socket.local_addr().ok().map(|a| a.ip()) +} + +fn call(name: &str, arguments: Json) -> MockToolCall { + MockToolCall { + provider_metadata: None, + name: name.into(), + arguments, + } +} + +/// Without `--live`: a scripted model that does what its reports say. Run +/// 1 plans, asks which format the notes should take (without waiting), +/// lists the folder, and reports; run 2, once the owner answered, writes +/// `notes.md` in that format and reports. +fn scripted() -> MockProvider { + MockProvider::new(vec![ + MockResponse::ToolCalls(vec![call( + "todo", + json!({ "action": "create", "subject": "List the folder and write notes.md" }), + )]), + MockResponse::ToolCalls(vec![ + call( + "ask_owner", + json!({ + "question": "Should notes.md be a bullet list or a short paragraph?", + "why": "Only the format of the notes depends on it.", + "options": ["Bullet list", "Short paragraph"] + }), + ), + call("list_files", json!({ "path": "." })), + ]), + MockResponse::Text( + "(scripted) ## Done\n- Listed the folder.\n\ + ## Waiting on you\n- #1 Should notes.md be a bullet list or a short paragraph?\n\ + ## Decisions\n- None yet.\n## Next\n- Write notes.md once #1 is answered." + .into(), + ), + MockResponse::ToolCalls(vec![ + call( + "write_file", + json!({ + "path": "notes.md", + "content": "# Notes\n\n- Written by the handoff agent's scripted demo.\n- A bullet list, as the owner chose.\n" + }), + ), + call("todo", json!({ "action": "update", "id": 1, "status": "completed" })), + ]), + MockResponse::Text( + "(scripted) ## Done\n- Wrote notes.md as a bullet list.\n## Waiting on you\n- Nothing.\n\ + ## Decisions\n- A bullet list, as you answered #1.\n## Next\n- Nothing." + .into(), + ), + ]) +} + +/// `--demo`: no browser; a frontend plays the owner — it waits for the run +/// to stop, then answers the question, which starts the second run. +async fn demo( + session: Session, + driver: yoagent_frontend::Driver, + agent: Agent, + task: Option, +) -> Result<(), BoxError> { + let mut owner = session.connect(); + let running = tokio::spawn(driver.run(agent)); + let task = task.unwrap_or_else(|| "Look at this folder and write notes.md about it.".into()); + println!("> {task}"); + session.send(ClientMessage::Prompt { text: task }); + let mut question = None; + let mut ended = 0; + let mut facts = Vec::new(); + while ended < 2 { + let message = tokio::time::timeout(Duration::from_secs(120), owner.messages.recv()) + .await + .map_err(|_| "the demo stalled")? + .ok_or("the session ended early")?; + match message { + ServerMessage::Event { event, .. } => match *event { + AgentEvent::ToolExecutionEnd { + tool_name, + is_error, + result, + .. + } => { + let why = if is_error { + let text: String = result + .content + .iter() + .filter_map(|c| match c { + Content::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect(); + format!(": {}", text.chars().take(160).collect::()) + } else { + String::new() + }; + println!(" {} {tool_name}{why}", if is_error { "✗" } else { "✓" }); + } + AgentEvent::MessageEnd { + message: AgentMessage::Llm(Message::Assistant { content, .. }), + } => { + for c in &content { + if let Content::Text { text } = c { + if !text.trim().is_empty() { + println!("{}", text.trim()); + } + } + } + } + _ => {} + }, + ServerMessage::UiRequest { id, request } => { + println!(" ? (to the owner) {}", request.title()); + question = Some(id); + } + ServerMessage::Notice { message, .. } => { + println!(" [{message}]"); + facts.push(message); + } + ServerMessage::RunEnd { outcome, error, .. } => { + ended += 1; + println!( + " — run {ended} ended: {outcome:?}{}", + error.map(|e| format!(" ({e})")).unwrap_or_default() + ); + if ended == 1 { + // The owner comes back, and answers. + let id = question.ok_or("the agent asked the owner nothing")?; + println!(" ! (the owner answers #1) Bullet list"); + session.send(ClientMessage::UiResponse { + id, + value: json!("Bullet list"), + }); + } + } + _ => {} + } + } + drop(owner); + session.send(ClientMessage::Quit); + let agent = tokio::time::timeout(Duration::from_secs(30), running) + .await + .map_err(|_| "the session did not end")??; + let answered = agent.messages().iter().any(|m| { + matches!(m, AgentMessage::Llm(Message::User { content, .. }) + if content.iter().any(|c| matches!(c, Content::Text { text } if text.starts_with("[Owner's answer to #1]")))) + }); + if !answered { + return Err("the owner's answer never reached the agent".into()); + } + if facts + .iter() + .filter(|f| f.starts_with("Handoff facts")) + .count() + != 2 + { + return Err(format!("expected the facts after each run: {facts:?}").into()); + } + // What the reports claim was done. + if !std::path::Path::new("notes.md").exists() { + return Err("the second run did not write notes.md".into()); + } + if !facts.last().is_some_and(|f| f.contains("new: notes.md")) { + return Err(format!("the facts do not name notes.md: {facts:?}").into()); + } + println!("ok: asked without waiting, answered later, wrote notes.md, two reports"); + Ok(()) +} diff --git a/integrations/yoagent-frontend/plugins/.npmrc b/integrations/yoagent-frontend/plugins/.npmrc new file mode 100644 index 00000000..aeaf68f0 --- /dev/null +++ b/integrations/yoagent-frontend/plugins/.npmrc @@ -0,0 +1,3 @@ +# pi extensions installed here (@juicesharp/rpiv-todo) list pi's own packages as +# peers; the pi adapter supplies those, so they are never installed here. +legacy-peer-deps=true diff --git a/integrations/yoagent-frontend/plugins/package-lock.json b/integrations/yoagent-frontend/plugins/package-lock.json index 56eb4ec6..f5e088b0 100644 --- a/integrations/yoagent-frontend/plugins/package-lock.json +++ b/integrations/yoagent-frontend/plugins/package-lock.json @@ -8,7 +8,9 @@ "dependencies": { "@arcships/rutis": "0.8.0", "@arcships/rutis-runtime": "0.8.0", - "@earendil-works/pi-tui": "1.1.0" + "@earendil-works/pi-tui": "1.1.0", + "@juicesharp/rpiv-i18n": "2.12.0", + "@juicesharp/rpiv-todo": "2.12.0" }, "engines": { "node": ">=24" @@ -498,6 +500,49 @@ "node": ">=18" } }, + "node_modules/@juicesharp/rpiv-config": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@juicesharp/rpiv-config/-/rpiv-config-2.12.0.tgz", + "integrity": "sha512-eGjoCDCKz2JtKIUIpZ2y8CAVjxZYXCKa2D64ByozhkAWVblXsgyFLrQMk5k5Bv5RXRrA3GY66XNG5ExtEuGASA==", + "license": "MIT", + "peerDependencies": { + "typebox": "*" + } + }, + "node_modules/@juicesharp/rpiv-i18n": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@juicesharp/rpiv-i18n/-/rpiv-i18n-2.12.0.tgz", + "integrity": "sha512-8w08+2j+fSqRTyi03bXkUQkf5HJYftLNjLNqk5xfmTy3oZ9xp7iDHtskuVQb55qQJ1k5gwKCycVezXckVqIoUQ==", + "license": "MIT", + "dependencies": { + "@juicesharp/rpiv-config": "^2.12.0" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*" + } + }, + "node_modules/@juicesharp/rpiv-todo": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@juicesharp/rpiv-todo/-/rpiv-todo-2.12.0.tgz", + "integrity": "sha512-bTILerGqMGrWMUkahHzXTuYa4iPkRX/LTl12HPppcCdPg7pGwrC1K7/ZtPfp7Xi4cXzqEoyZpeUM6IA8y5gtEQ==", + "license": "MIT", + "dependencies": { + "@juicesharp/rpiv-config": "^2.12.0" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + "@juicesharp/rpiv-i18n": "*", + "typebox": "*" + }, + "peerDependenciesMeta": { + "@juicesharp/rpiv-i18n": { + "optional": true + } + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", diff --git a/integrations/yoagent-frontend/plugins/package.json b/integrations/yoagent-frontend/plugins/package.json index c29e482c..ae5a4b39 100644 --- a/integrations/yoagent-frontend/plugins/package.json +++ b/integrations/yoagent-frontend/plugins/package.json @@ -9,6 +9,8 @@ "dependencies": { "@arcships/rutis": "0.8.0", "@arcships/rutis-runtime": "0.8.0", - "@earendil-works/pi-tui": "1.1.0" + "@earendil-works/pi-tui": "1.1.0", + "@juicesharp/rpiv-i18n": "2.12.0", + "@juicesharp/rpiv-todo": "2.12.0" } } diff --git a/integrations/yoagent-frontend/plugins/pi-extensions/confirm-dangerous.ts b/integrations/yoagent-frontend/plugins/pi-extensions/confirm-dangerous.ts index da4a14c6..de0942cc 100644 --- a/integrations/yoagent-frontend/plugins/pi-extensions/confirm-dangerous.ts +++ b/integrations/yoagent-frontend/plugins/pi-extensions/confirm-dangerous.ts @@ -1,22 +1,110 @@ -// A pi extension in the style of pi's own permission gate: a dangerous bash -// command or a write to a protected path asks the user first. In pi's print -// mode (no UI) it blocks them; with a frontend attached through +// A pi extension in the style of pi's own permission gate: anything that +// could destroy or overwrite what the owner already has asks first. In pi's +// print mode (no UI) it blocks; with a frontend attached through // yoagent-frontend, the question reaches the terminal or browser. +// +// What asks: +// - shell commands that delete, discard or overwrite: `rm`, `git reset +// --hard`, `git clean`, `git checkout -- …` / `git restore`, `git branch +// -D`, a forced push, `find … -delete`, `truncate`, `shred`, `mkfs`, `dd`, +// `sudo`, recursive `chmod` / `chown`, and `mv` or a `>` redirection onto +// a file that exists; +// - a write over a file that exists, and any change to `.env` or `.git/`. +// What does not: files the agent created in this session (its deliverable, +// kept up to date) and anything in its scratch folder, when the host names +// one in `YOAGENT_SCRATCH` (the handoff agent does). Paths are compared +// with symlinks resolved (on macOS /tmp is /private/tmp). -const DANGEROUS = /\b(rm\s+-[a-z]*r|sudo|chmod\s+-R|git\s+push\s+--force|mkfs|dd\s+if=)/ +import { existsSync, realpathSync } from 'node:fs' +import { basename, dirname, isAbsolute, resolve, sep } from 'node:path' + +const ALWAYS = [ + /\bsudo\b/, + /\bgit\s+reset\s+--hard\b/, + /\bgit\s+clean\b/, + /\bgit\s+(checkout|restore)\b[^;&|]*(\s--\s|\s\.(\s|$))/, + /\bgit\s+branch\s+-D\b/, + /\bgit\s+push\b[^;&|]*\s(--force\b|-f\b)/, + /\bfind\b[^;&|]*\s-delete\b/, + /\b(truncate|shred|mkfs(\.\w+)?)\b/, + /\bdd\s+if=/, + /\bch(mod|own)\s+-R\b/, +] const PROTECTED = /(^|\/)(\.env|\.git\/)/ +/** `path` with symlinks resolved, also for a file not created yet. */ +function real(path: string): string { + try { + return realpathSync(path) + } catch { + const parent = dirname(path) + return parent === path ? path : resolve(real(parent), basename(path)) + } +} + +const SCRATCH = process.env.YOAGENT_SCRATCH ? real(resolve(process.env.YOAGENT_SCRATCH)) : undefined + +/** The words of each simple command in `command` (split on ; && || |). */ +const segments = (command: string) => + command + .split(/;|&&|\|\||\|/) + .map((part) => part.trim().split(/\s+/).filter(Boolean)) + .filter((words) => words.length > 0) + export default function (pi: any) { + // Files the agent made this session: it may overwrite or delete them. + const created = new Set() + const where = (cwd: string, file: string) => real(isAbsolute(file) ? resolve(file) : resolve(cwd, file)) + const free = (path: string) => + created.has(path) || (SCRATCH !== undefined && (path === SCRATCH || path.startsWith(SCRATCH + sep))) + // A path the agent may not touch without asking: one that exists and is not its own. + const owners = (path: string) => !free(path) && existsSync(path) + + /** Why this command needs the owner's yes, or nothing. */ + const risky = (command: string, cwd: string): boolean => { + if (ALWAYS.some((pattern) => pattern.test(command))) return true + for (const words of segments(command)) { + const program = words[0] + const args = words.slice(1).filter((word) => !word.startsWith('-')) + if (program === 'rm' || program === 'unlink' || program === 'rmdir') { + // Globs and expansions cannot be checked: ask. + if (args.length === 0 || args.some((a) => /[*?$`{]/.test(a))) return true + if (args.some((a) => !free(where(cwd, a)))) return true + } + if (program === 'mv' || program === 'cp') { + const target = args.at(-1) + if (target && owners(where(cwd, target))) return true + } + } + // `>` (not `>>`, `2>`, `>&`) onto a file that exists and is not the agent's. + for (const match of command.matchAll(/(?&\d])>(?![>&])\s*([^\s;&|<>]+)/g)) { + const path = where(cwd, match[1].replace(/^["']|["']$/g, '')) + if (owners(path)) return true + if (!existsSync(path)) created.add(path) + } + return false + } + pi.on('tool_call', async (event: any, ctx: any) => { - const subject = - event.toolName === 'bash' - ? DANGEROUS.test(String(event.input?.command ?? '')) && `Run \`${event.input.command}\`?` - : ['write', 'edit'].includes(event.toolName) && PROTECTED.test(String(event.input?.path ?? '')) - ? `Change the protected file ${event.input.path}?` - : false - if (!subject) return undefined - if (!ctx.hasUI) return { block: true, reason: 'needs confirmation, and no one is there to ask' } - const choice = await ctx.ui.select(subject, ['Allow', 'Block']) - return choice === 'Allow' ? undefined : { block: true, reason: 'the user blocked it' } + const cwd = ctx.cwd ?? process.cwd() + let subject: string | false = false + let creating: string | undefined + if (event.toolName === 'bash') { + const command = String(event.input?.command ?? '') + if (risky(command, cwd)) subject = `Run \`${command}\`?` + } else if (['write', 'edit'].includes(event.toolName)) { + const file = String(event.input?.path ?? '') + const path = where(cwd, file) + if (PROTECTED.test(file)) subject = `Change the protected file ${file}?` + else if (event.toolName === 'write' && owners(path)) subject = `Overwrite the existing file ${file}?` + else if (event.toolName === 'write' && !existsSync(path)) creating = path + } + if (subject) { + if (!ctx.hasUI) return { block: true, reason: 'needs confirmation, and no one is there to ask' } + const choice = await ctx.ui.select(subject, ['Allow', 'Block']) + if (choice !== 'Allow') return { block: true, reason: 'the user blocked it' } + } + if (creating) created.add(creating) + return undefined }) } diff --git a/integrations/yoagent-frontend/plugins/todo-plan.ts b/integrations/yoagent-frontend/plugins/todo-plan.ts new file mode 100644 index 00000000..66e86651 --- /dev/null +++ b/integrations/yoagent-frontend/plugins/todo-plan.ts @@ -0,0 +1,45 @@ +// EXPERIMENTAL. A UI plugin for the handoff agent: draws the plan the pi +// extension @juicesharp/rpiv-todo keeps (its `todo` tool returns the whole +// task list in `details.tasks`) as a checklist under each `todo` call, so +// the owner sees where the work stands from any browser. + +import { definePlugin } from '@arcships/rutis' + +interface Frontend { + addUiPlugin(info: { name: string; tools: string[]; panel: boolean }, module: string): () => void +} + +// The browser side. Plain DOM, no dependencies; runs in the page. +const MODULE = String.raw` +const MARK = { completed: '✓', in_progress: '▸', pending: '○' } + +export function renderTool({ result, isError }, element) { + const tasks = result?.details?.tasks + if (isError || !Array.isArray(tasks)) return + const shown = tasks.filter((task) => task.status !== 'deleted') + if (shown.length === 0) return + const done = shown.filter((task) => task.status === 'completed').length + const head = document.createElement('div') + head.textContent = 'Plan · ' + done + '/' + shown.length + ' done' + head.style.cssText = 'font-size:12px;opacity:.7;margin:4px 0 2px' + const list = document.createElement('ul') + list.style.cssText = 'margin:0 0 4px;padding-left:4px;list-style:none;font-size:13px' + for (const task of shown) { + const item = document.createElement('li') + const mark = MARK[task.status] ?? '○' + item.textContent = mark + ' ' + (task.status === 'in_progress' && task.activeForm ? task.activeForm : task.subject) + if (task.status === 'completed') item.style.opacity = '.6' + if (task.status === 'in_progress') item.style.fontWeight = '600' + list.append(item) + } + element.append(head, list) +} +` + +export default definePlugin({ + inject: ['frontend'], + apply(ctx) { + const frontend = ctx.use('frontend') + ctx.effect(frontend.addUiPlugin({ name: 'todo-plan', tools: ['todo'], panel: false }, MODULE)) + }, +}) diff --git a/integrations/yoagent-frontend/src/session.rs b/integrations/yoagent-frontend/src/session.rs index a658e63e..88ff4f1d 100644 --- a/integrations/yoagent-frontend/src/session.rs +++ b/integrations/yoagent-frontend/src/session.rs @@ -6,7 +6,7 @@ //! channel — in order, none dropped — so `RunEnd` arrives even when a slow //! frontend fell behind on streamed text. -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -26,6 +26,11 @@ const COALESCE: Duration = Duration::from_millis(30); /// How long a plugin's question waits for an answer before the safe default. pub const UI_TIMEOUT: Duration = Duration::from_secs(300); +/// How many of the latest runs' messages (`runStart`, `event`, `runEnd`, +/// `notice`) the session keeps to replay to a frontend that connects later, +/// so a page opened mid-run or after it shows what happened. +pub const HISTORY: usize = 5000; + /// How long a withdrawal for a question not asked yet is remembered: the /// asker's `withdraw` can overtake its own `request` on the way to the host. const EARLY_WITHDRAWAL: Duration = Duration::from_secs(60); @@ -40,6 +45,9 @@ struct UiState { keys: HashMap, /// Keys withdrawn before their question was asked. early: HashMap, + /// Questions asked with [`Session::ask_later`]: they belong to the + /// conversation, so a reset withdraws them. + held: HashSet, } struct Inner { @@ -52,6 +60,10 @@ struct Inner { running: AtomicBool, /// The driver ended (or failed): nothing sent is heard any more. closed: AtomicBool, + /// The latest [`HISTORY`] run messages, oldest first. Written under the + /// `clients` lock (then this one), so a frontend connecting gets each + /// message once: in its replay, or live. + history: Mutex>, } /// The session handle. @@ -92,6 +104,7 @@ impl Session { plugin_versions: AtomicU64::new(0), running: AtomicBool::new(false), closed: AtomicBool::new(false), + history: Mutex::default(), }), commands: tx, }; @@ -106,8 +119,10 @@ impl Session { (session, driver) } - /// Attach a frontend. Its first message is [`ServerMessage::Hello`] - /// (then [`ServerMessage::Closed`] at once, if the session already ended). + /// Attach a frontend. Its first message is [`ServerMessage::Hello`], then + /// the session's recent runs as they were broadcast (up to [`HISTORY`] + /// messages, oldest first: a page opened late shows what happened), then + /// [`ServerMessage::Closed`] at once if the session already ended. pub fn connect(&self) -> Connection { let id = self.inner.next_client.fetch_add(1, Ordering::Relaxed); let (tx, rx) = mpsc::unbounded_channel(); @@ -131,6 +146,9 @@ impl Session { ui_plugins: self.ui_plugins(), ui_requests, }); + for message in self.inner.history.lock().unwrap().iter() { + let _ = tx.send(message.clone()); + } if self.inner.closed.load(Ordering::SeqCst) { let _ = tx.send(ServerMessage::Closed); } @@ -219,8 +237,32 @@ impl Session { timeout: Duration, key: Option, ) -> Json { - let request = request.normalized(); let default = request.default_answer(); + self.ask_inner(request, Some(timeout), key, false) + .await + .unwrap_or(default) + } + + /// Ask, and keep the question open until it is answered — however long + /// that takes, and with no frontend attached: one that connects later + /// gets it in its `Hello`. For an agent working while its owner is away + /// (see the `handoff_agent` example). `Some(answer)` when a frontend + /// answered (`null` when the user dismissed it); `None` when nobody + /// will: the question was withdrawn — a reset withdraws these, since + /// they belong to the forgotten conversation — or the session ended. A + /// `notify` is shown and returns `Some(null)` at once. + pub async fn ask_later(&self, request: UiRequest) -> Option { + self.ask_inner(request, None, None, true).await + } + + async fn ask_inner( + &self, + request: UiRequest, + timeout: Option, + key: Option, + hold: bool, + ) -> Option { + let request = request.normalized(); let expects = request.expects_answer(); let (tx, rx) = oneshot::channel(); // Before the `ui` lock: `connect` takes `clients` then `ui`, so @@ -232,22 +274,30 @@ impl Session { if let Some(key) = &key { if ui.early.remove(key).is_some() { tracing::debug!(%key, "a question withdrawn before it was asked"); - return default; + return None; } } - if !attached { + // Checked under `ui`: the session's end clears `pending` under it + // too, so a question is either refused here or cleared there. + if self.is_closed() { + return None; + } + if !attached && !hold { drop(ui); tracing::info!( title = request.title(), "no frontend to ask: the safe default" ); - return default; + return None; } ui.next_id += 1; let id = ui.next_id; let mut owned_key = None; if expects { ui.pending.insert(id, (tx, request.clone())); + if hold { + ui.held.insert(id); + } if let Some(key) = key { if ui.keys.contains_key(&key) { tracing::warn!(%key, "a question key already in use: this one cannot be withdrawn by key"); @@ -262,7 +312,7 @@ impl Session { let title = request.title().to_owned(); self.broadcast(ServerMessage::UiRequest { id, request }); if !expects { - return Json::Null; + return Some(Json::Null); } /// Closes the question however `ask` ends — answered, timed out, /// withdrawn by key, or dropped — and tells the frontends why. @@ -277,6 +327,7 @@ impl Session { { let mut ui = self.session.inner.ui.lock().unwrap(); ui.pending.remove(&self.id); + ui.held.remove(&self.id); if let Some(key) = &self.key { if ui.keys.get(key) == Some(&self.id) { ui.keys.remove(key); @@ -295,16 +346,20 @@ impl Session { key, reason: ResolveReason::Withdrawn, }; - match tokio::time::timeout(timeout, rx).await { + let answered = match timeout { + Some(limit) => tokio::time::timeout(limit, rx).await, + None => Ok(rx.await), + }; + match answered { Ok(Ok(value)) => { close.reason = ResolveReason::Answered; - value + Some(value) } - Ok(Err(_)) => default, + Ok(Err(_)) => None, Err(_) => { close.reason = ResolveReason::TimedOut; tracing::info!(%title, "no answer in time: the safe default"); - default + None } } } @@ -384,11 +439,35 @@ impl Session { } impl Inner { + /// Withdraw every question asked with [`Session::ask_later`] (a reset). + fn withdraw_held(&self) { + let mut ui = self.ui.lock().unwrap(); + let held: Vec = ui.held.drain().collect(); + for id in held { + // Dropping the waiter ends its `ask_later` with `None`; its guard + // tells the frontends. + ui.pending.remove(&id); + } + } + fn broadcast(&self, message: ServerMessage) { - self.clients - .lock() - .unwrap() - .retain(|_, tx| tx.send(message.clone()).is_ok()); + let mut clients = self.clients.lock().unwrap(); + // Kept for later frontends: what a run showed. Questions are not + // (Hello carries the open ones), nor the session's own messages. + if matches!( + message, + ServerMessage::RunStart { .. } + | ServerMessage::Event { .. } + | ServerMessage::RunEnd { .. } + | ServerMessage::Notice { .. } + ) { + let mut history = self.history.lock().unwrap(); + if history.len() == HISTORY { + history.pop_front(); + } + history.push_back(message.clone()); + } + clients.retain(|_, tx| tx.send(message.clone()).is_ok()); } } @@ -417,6 +496,9 @@ impl Drop for Ending { let mut clients = self.inner.clients.lock().unwrap(); self.inner.closed.store(true, Ordering::SeqCst); clients.retain(|_, tx| tx.send(ServerMessage::Closed).is_ok()); + // Open questions end with their default (a question held for an + // absent owner would otherwise wait forever). `clients` then `ui`. + self.inner.ui.lock().unwrap().pending.clear(); } } @@ -440,6 +522,7 @@ impl Driver { ) => text, Some(ClientMessage::Reset) => { agent.clear_messages(); + inner.withdraw_held(); continue; } Some(ClientMessage::Quit) if self.accept_quit => break, @@ -518,6 +601,22 @@ impl Driver { agent.finish().await; if reset { agent.clear_messages(); + inner.withdraw_held(); + agent.clear_steering_queue(); + agent.clear_follow_up_queue(); + } else if !aborted { + // Steering or a follow-up that arrived after the loop's last + // look (while the answer streamed, or extensions finished): + // the next run's prompt, not left for some later run. + let late: Vec = agent + .take_steering_queue() + .into_iter() + .chain(agent.take_follow_up_queue()) + .filter_map(|m| text_of(&m)) + .collect(); + if !late.is_empty() { + queue.push_front(late.join("\n\n")); + } } let saw_end = end.is_some(); let (stats, last) = end.unwrap_or_default(); @@ -562,6 +661,21 @@ fn user(text: String) -> AgentMessage { AgentMessage::Llm(Message::user(text)) } +/// A queued user message's text (the session only queues text). +fn text_of(message: &AgentMessage) -> Option { + let AgentMessage::Llm(Message::User { content, .. }) = message else { + return None; + }; + let text: Vec<&str> = content + .iter() + .filter_map(|c| match c { + yoagent::Content::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect(); + (!text.is_empty()).then(|| text.join("\n")) +} + /// How the run ended, from its last assistant message only (an earlier /// message's outcome says nothing about how the run ended). fn run_error(messages: &[AgentMessage]) -> Option<(RunOutcome, String)> { diff --git a/integrations/yoagent-frontend/tests/session_test.rs b/integrations/yoagent-frontend/tests/session_test.rs index 02a79ec4..3ea54eda 100644 --- a/integrations/yoagent-frontend/tests/session_test.rs +++ b/integrations/yoagent-frontend/tests/session_test.rs @@ -328,10 +328,68 @@ async fn a_driver_that_dies_mid_run_ends_the_run_and_the_session() { assert!(error.is_some()); assert!(matches!(next(&mut ui).await, ServerMessage::Closed)); assert!(!session.send(ClientMessage::Prompt { text: "x".into() })); - // A frontend connecting now learns at once that the session ended. + // A frontend connecting now gets the run that died (the replay), then + // learns that the session ended. let mut late = session.connect(); assert!(matches!(next(&mut late).await, ServerMessage::Hello { .. })); - assert!(matches!(next(&mut late).await, ServerMessage::Closed)); + let mut replay = Vec::new(); + loop { + match next(&mut late).await { + ServerMessage::Closed => break, + other => replay.push(other), + } + } + assert!(matches!( + replay.first(), + Some(ServerMessage::RunStart { .. }) + )); + assert!(matches!( + replay.last(), + Some(ServerMessage::RunEnd { + outcome: RunOutcome::Error, + .. + }) + )); +} + +/// A frontend that connects after runs gets them replayed after its Hello, +/// in order, then live messages — each message once. +#[tokio::test] +async fn a_late_frontend_gets_the_runs_so_far() { + let (session, driver) = Session::new(true); + let mut early = session.connect(); + next(&mut early).await; + let run = tokio::spawn(driver.run(agent(vec![ + MockResponse::Text("first answer".into()), + MockResponse::Text("second answer".into()), + ]))); + session.send(ClientMessage::Prompt { text: "one".into() }); + let shown = until_run_end(&mut early).await; + session.notice(NoticeLevel::Info, "facts"); + next(&mut early).await; + + let mut late = session.connect(); + assert!(matches!( + next(&mut late).await, + ServerMessage::Hello { running: false, .. } + )); + // The run as the early frontend saw it, and the notice. + for message in &shown { + assert_eq!(&next(&mut late).await, message); + } + assert!( + matches!(next(&mut late).await, ServerMessage::Notice { message, .. } if message == "facts") + ); + // Then live: the next run reaches both, once. + session.send(ClientMessage::Prompt { text: "two".into() }); + let live = until_run_end(&mut late).await; + assert!( + matches!(&live[0], ServerMessage::RunStart { run: 2, .. }), + "{live:?}" + ); + assert_eq!(live, until_run_end(&mut early).await); + session.send(ClientMessage::Quit); + finished(run).await; } /// An abort mid-run is reported as such; a reset mid-run also drops the @@ -639,3 +697,161 @@ async fn a_select_is_asked_with_each_option_once() { }); assert_eq!(asking.await.unwrap(), json!(["b", "a"])); } + +/// `ask_later`: with no frontend attached the question waits (no default); +/// a frontend that connects later gets it in its Hello and answers it. +#[tokio::test] +async fn a_question_asked_later_waits_for_a_frontend() { + let (session, _driver) = Session::new(true); + let asking = { + let session = session.clone(); + tokio::spawn(async move { session.ask_later(confirm("Keep the old loader?")).await }) + }; + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!asking.is_finished(), "no frontend: it waits"); + let mut phone = session.connect(); + let ServerMessage::Hello { ui_requests, .. } = next(&mut phone).await else { + panic!("Hello") + }; + assert_eq!(ui_requests.len(), 1); + let id = ui_requests[0].id; + assert!(session.send(ClientMessage::UiResponse { + id, + value: serde_json::json!(true), + })); + assert_eq!(asking.await.unwrap(), Some(serde_json::json!(true))); + assert_eq!(resolved(&mut phone).await, (id, ResolveReason::Answered)); +} + +/// A question asked later ends with its default when the session ends, and +/// none is opened after. +#[tokio::test] +async fn a_question_asked_later_ends_with_the_session() { + let (session, driver) = Session::new(true); + let asking = { + let session = session.clone(); + tokio::spawn(async move { session.ask_later(confirm("Keep the old loader?")).await }) + }; + tokio::time::sleep(Duration::from_millis(50)).await; + drop(driver); + let answer = tokio::time::timeout(Duration::from_secs(5), asking) + .await + .expect("the question ends with the session") + .unwrap(); + // Nobody will answer: None, not a made-up default. + assert_eq!(answer, None); + let late = tokio::time::timeout( + Duration::from_secs(5), + session.ask_later(confirm("Another?")), + ) + .await + .expect("refused at once on an ended session"); + assert_eq!(late, None); +} + +/// Holds the run's `finish` until released: a window after the loop's last +/// look at the steering queue, before the run ends. +struct HoldFinish { + entered: Arc, + release: Arc, +} + +#[async_trait::async_trait] +impl yoagent::extension::Extension for HoldFinish { + fn name(&self) -> &str { + "hold-finish" + } + async fn start_run( + &self, + _run: &yoagent::extension::RunContext<'_>, + ) -> Result, yoagent::extension::ExtensionError> { + Ok(Box::new(HoldFinishRun { + entered: self.entered.clone(), + release: self.release.clone(), + })) + } +} + +struct HoldFinishRun { + entered: Arc, + release: Arc, +} + +#[async_trait::async_trait] +impl yoagent::extension::RunHooks for HoldFinishRun { + async fn finish(&mut self, _outcome: &yoagent::extension::RunOutcome) { + self.entered.notify_one(); + let _ = self.release.acquire().await.map(|p| p.forget()); + } +} + +/// A steer that arrives after the run's loop last looked (while extensions +/// finish) starts the next run instead of waiting for some later one. +#[tokio::test] +async fn a_steer_arriving_as_a_run_ends_starts_the_next_run() { + let (session, driver) = Session::new(true); + let mut ui = session.connect(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(Semaphore::new(0)); + let agent = agent(vec![ + MockResponse::Text("first".into()), + MockResponse::Text("second".into()), + ]) + .with_extension(HoldFinish { + entered: entered.clone(), + release: release.clone(), + }); + let run = tokio::spawn(driver.run(agent)); + session.send(ClientMessage::Prompt { text: "go".into() }); + tokio::time::timeout(WAIT, entered.notified()) + .await + .expect("finish starts"); + session.send(ClientMessage::Steer { + text: "late answer".into(), + }); + // Let the driver hand the steer to the agent before the run ends. + tokio::time::sleep(Duration::from_millis(100)).await; + release.add_permits(2); + until_run_end(&mut ui).await; + let seen = until_run_end(&mut ui).await; + assert!( + matches!(&seen[0], ServerMessage::RunStart { run: 2, prompt } if prompt == "late answer"), + "{seen:?}" + ); + session.send(ClientMessage::Quit); + finished(run).await; +} + +/// A reset forgets the conversation, so the questions held for its owner +/// are withdrawn: the asker gets `None` (no answer to act on) and frontends +/// close them. A question asked with a timeout is not affected. +#[tokio::test] +async fn a_reset_withdraws_the_questions_held_for_the_owner() { + let (session, driver) = Session::new(true); + let mut ui = session.connect(); + next(&mut ui).await; + let run = tokio::spawn(driver.run(agent(vec![]))); + let held = { + let session = session.clone(); + tokio::spawn(async move { session.ask_later(confirm("Keep the old loader?")).await }) + }; + let held_id = asked(&mut ui).await; + let timed = ask( + &session, + confirm("Delete build?"), + Duration::from_secs(60), + None, + ); + let timed_id = asked(&mut ui).await; + session.send(ClientMessage::Reset); + assert_eq!(held.await.unwrap(), None, "withdrawn: nothing to act on"); + assert_eq!(resolved(&mut ui).await, (held_id, ResolveReason::Withdrawn)); + // The other question is still open, and answerable. + session.send(ClientMessage::UiResponse { + id: timed_id, + value: json!(true), + }); + assert_eq!(timed.await.unwrap(), json!(true)); + session.send(ClientMessage::Quit); + finished(run).await; +} diff --git a/integrations/yoagent-frontend/web/app.js b/integrations/yoagent-frontend/web/app.js index 12edb566..43054151 100644 --- a/integrations/yoagent-frontend/web/app.js +++ b/integrations/yoagent-frontend/web/app.js @@ -269,7 +269,9 @@ function onEvent(event) { } } -// One dialog at a time, the next when it closes. +// One question at a time, the next when it closes. Docked, not modal: the +// page stays usable — read what the agent did, scroll, even type to it — +// and the question is answered when the user is ready. const questions = new Questions() function ask(id, request) { @@ -277,16 +279,33 @@ function ask(id, request) { add(request.level === 'error' ? 'notice error' : 'notice', request.message) return } - if (questions.add(id, request)) showNextQuestion() + if (questions.add(id, request)) { + showNextQuestion() + showCount() + } +} + +// "1 of 3 waiting" on the shown question, when more wait behind it. +function showCount() { + const count = dialog.querySelector('.count') + if (!count) return + const waiting = questions.waiting.length + count.textContent = waiting > 1 ? `1 of ${waiting} questions waiting` : '' +} + +function closeQuestion() { + if (dialog.open) dialog.close() + document.body.classList.remove('asking') } function resolved(id, reason) { const shown = questions.remove(id) if (reason === 'timedOut') add('notice', 'A question timed out: the safe answer was used.') if (shown && dialog.open) { - dialog.close() + closeQuestion() showNextQuestion() } + showCount() } function showNextQuestion() { @@ -294,6 +313,7 @@ function showNextQuestion() { const { id, request } = questions.current() dialog.replaceChildren() dialog.dataset.id = id + dialog.append(el('div', 'count')) const title = document.createElement('h3') title.textContent = request.title dialog.append(title) @@ -348,7 +368,9 @@ function showNextQuestion() { e.preventDefault() reply(request.kind === 'confirm' ? false : null) } - dialog.showModal() + document.body.classList.add('asking') + dialog.show() + showCount() } socket.onopen = () => (status.textContent = 'connected') @@ -357,7 +379,7 @@ socket.onclose = () => { status.textContent = token ? 'disconnected: reload to reconnect' : 'not connected: open the URL the server printed (it carries a token)' // Nothing can be answered from here any more. questions.clear() - if (dialog.open) dialog.close() + closeQuestion() } // One message at a time, in order: a message waits for the previous one // (a plugin import, say) to finish. @@ -402,7 +424,7 @@ async function handle(message) { ended = true status.textContent = 'session closed' questions.clear() - if (dialog.open) dialog.close() + closeQuestion() return default: // A newer server's message: nothing to show. diff --git a/integrations/yoagent-frontend/web/index.html b/integrations/yoagent-frontend/web/index.html index 654c5074..d2790d7b 100644 --- a/integrations/yoagent-frontend/web/index.html +++ b/integrations/yoagent-frontend/web/index.html @@ -64,6 +64,11 @@ dialog label.choice { display: block; margin: 4px 0; } dialog label.choice input { width: auto; } dialog { border: 1px solid var(--line); border-radius: 12px; background: var(--panel); color: var(--ink); max-width: 420px; width: calc(100% - 32px); } + /* Docked above the composer, not modal: the page stays usable while a question waits. */ + dialog:not(:modal) { position: fixed; left: 0; right: 0; bottom: 76px; margin: 0 auto; z-index: 10; box-shadow: 0 8px 30px rgba(0, 0, 0, .45); max-height: 60vh; overflow-y: auto; } + dialog .count { color: var(--muted); font-size: 12px; } + dialog .count:empty { display: none; } + body.asking #log { padding-bottom: 45vh; } dialog h3 { margin: 0 0 6px; font-size: 16px; } dialog .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; flex-wrap: wrap; } dialog input { width: 100%; padding: 8px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); font: inherit; } diff --git a/integrations/yoagent-rutis/CLAUDE.md b/integrations/yoagent-rutis/CLAUDE.md index 2ded7acd..b70541bb 100644 --- a/integrations/yoagent-rutis/CLAUDE.md +++ b/integrations/yoagent-rutis/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code when working in `integrations/yoagent-rutis/`. The repo ## rutis bridge (`integrations/yoagent-rutis/`, separate crate) -Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.8"` (0.x caret = 0.8.x; since 0.8 the packages in rutis's release train share one version; any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Python plugins** (`languages.rs`, features `node`/`python`/`websocket` → optional `rutis-bridge = "0.8"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.8 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (websocket frame limit 16 MiB), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7+ allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.8.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.8.0; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `console.warn` each (`reported` set; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.8.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.8.0), `requirements.txt` (`rutis==0.8.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 24 / `plugins/node_modules` / Python with rutis 0.8 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.86, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 24, Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. +Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.8"` (0.x caret = 0.8.x; since 0.8 the packages in rutis's release train share one version; any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Python plugins** (`languages.rs`, features `node`/`python`/`websocket` → optional `rutis-bridge = "0.8"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.8 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (websocket frame limit 16 MiB), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7+ allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.8.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.8.0; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `info` report each (`reported` set; expected, not a fault — refusals stay `warn`; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.8.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.8.0), `requirements.txt` (`rutis==0.8.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 24 / `plugins/node_modules` / Python with rutis 0.8 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.86, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 24, Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. ### Plugin compatibility matrix diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts index 2a1823c9..dca9419d 100644 --- a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -115,8 +115,8 @@ // registered after load) refuse the load unless listed in `allowUnmapped`; the rest (observers such as `agent_end` or // `tool_execution_*`, pi's session events, the boundary events `turn_end` / // `agent_before_settle`), commands, shortcuts, flags, renderers, model -// providers, virtual models and MCP servers are reported (a warning, or a -// load failure with `strict`). One registered after load (in a handler or a +// providers, virtual models and MCP servers are reported (at info: expected, +// not a fault; a load failure with `strict`). One registered after load (in a handler or a // tool) is reported at the next model request or tool call, and a deciding // one stops the adapter: that run, and every later call, refused. Warnings // go to the host's logs (`yoagent.log`, else stderr). `pi.appendEntry` and @@ -829,7 +829,9 @@ export default definePlugin({ if (newIgnored.length > 0) { const text = `not available in yoagent, ignored: ${newIgnored.join('; ')}` if (atLoad && config.strict) throw new Error(`pi extensions use what does not map — ${text}`) - report('warn', `[pi] ${text}`) + // Expected, not a fault: app features (commands, shortcuts, events yoagent never fires) + // a library has no place for. Refusals, which are faults, stay warnings. + report('info', `[pi] ${text}`) } } diff --git a/integrations/yoagent-rutis/tests/pi_log_test.rs b/integrations/yoagent-rutis/tests/pi_log_test.rs index 3a3f9467..f29b6e52 100644 --- a/integrations/yoagent-rutis/tests/pi_log_test.rs +++ b/integrations/yoagent-rutis/tests/pi_log_test.rs @@ -102,7 +102,8 @@ async fn a_plugin_warning_reaches_the_hosts_logs() { .unwrap() .iter() .find(|l| { - l.starts_with("yoagent_rutis::plugin WARN") && l.contains("command /fixture") + // What does not map is reported at info: expected, not a fault. + l.starts_with("yoagent_rutis::plugin INFO") && l.contains("command /fixture") }) .cloned(); if let Some(line) = hit { @@ -114,7 +115,7 @@ async fn a_plugin_warning_reaches_the_hosts_logs() { .await .unwrap_or_else(|_| { panic!( - "no plugin warning in the host's logs: {:?}", + "no plugin report in the host's logs: {:?}", logs.0.lock().unwrap() ) }); diff --git a/scripts/ecosystem-matrix.mjs b/scripts/ecosystem-matrix.mjs index 632a2962..1aeef67e 100644 --- a/scripts/ecosystem-matrix.mjs +++ b/scripts/ecosystem-matrix.mjs @@ -104,7 +104,9 @@ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}` function classifyPi(r) { const warnings = (r.logs ?? []).filter((l) => l.level === 'warn').map((l) => l.message) - const ignored = warnings.flatMap((w) => (w.match(/ignored: (.*)$/)?.[1] ?? '').split('; ').filter(Boolean)).map((s) => s.replace(/^[^:]+: /, '')) + // Reported at info by the adapter now (at warn by older versions). + const reports = (r.logs ?? []).filter((l) => l.level === 'warn' || l.level === 'info').map((l) => l.message) + const ignored = reports.flatMap((w) => (w.match(/ignored: (.*)$/)?.[1] ?? '').split('; ').filter(Boolean)).map((s) => s.replace(/^[^:]+: /, '')) const skills = r.skills?.loaded ?? [] const skillErrors = r.skills?.errors ?? [] if (r.install) return { result: 'not installed', detail: r.install } diff --git a/scripts/try-handoff.sh b/scripts/try-handoff.sh new file mode 100755 index 00000000..e5646373 --- /dev/null +++ b/scripts/try-handoff.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Hand a task to the handoff agent example +# (integrations/yoagent-frontend/examples/handoff_agent.rs) and walk away. It +# works in the directory you run this from — the project you hand off — and +# prints a URL to follow it, answer its questions and read its reports. +# +# mkdir research && cd research && git init +# path/to/yoagent/scripts/try-handoff.sh --live "research the best headphones; ask my budget" +# path/to/yoagent/scripts/try-handoff.sh --phone "…" # serve on the LAN, for a phone +# +# Other flags go to the example: --demo, --no-dsh, --max-minutes N, +# --budget-usd X, --web ADDR. Needs Rust (cargo), Node 24+ and npm. +set -euo pipefail + +repo=$(cd "$(dirname "$0")/.." && pwd) +for arg in "$@"; do + case "$arg" in + -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --live) + [ -n "${DEEPSEEK_API_KEY:-}" ] || { echo "error: --live needs DEEPSEEK_API_KEY" >&2; exit 1; } ;; + esac +done +# The plugin runtimes (installed once; checks Rust, Node and npm first). +"$repo/scripts/try-coding-agent.sh" --setup-only >/dev/null +exec cargo run --quiet --manifest-path "$repo/integrations/yoagent-frontend/Cargo.toml" \ + --example handoff_agent -- "$@"