From e8b68296e00f2d7e9fd178c7802d5ebc508c327c Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 10:48:47 +0200 Subject: [PATCH 01/15] =?UTF-8?q?feat(rutis):=20pi=20extensions=20adapter?= =?UTF-8?q?=20=E2=80=94=20pi=20tools=20and=20tool=20policies=20in=20yoagen?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plugins/pi/pi-extensions-adapter.ts loads pi coding-agent extensions unchanged with pi's own loader (pi 1.1.0 packages installed in plugins/pi/) and maps registerTool, tool_call, tool_result, before_agent_start and session_start/shutdown onto one yoagent handler; everything else is reported (strict: load failure). Fixture extension, tests/pi_test.rs, examples/pi_extensions.rs (any extension files; --live with DeepSeek); CI installs plugins/pi and runs the scripted example. Tried with 11 of pi's own examples, unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .github/workflows/ci.yml | 6 + CHANGELOG.md | 6 + CLAUDE.md | 2 +- integrations/yoagent-rutis/Cargo.toml | 8 + integrations/yoagent-rutis/README.md | 52 +- .../yoagent-rutis/examples/pi_extensions.rs | 277 +++ .../plugins/pi/fixture-extension.ts | 96 + .../plugins/pi/package-lock.json | 2072 +++++++++++++++++ .../yoagent-rutis/plugins/pi/package.json | 18 + .../plugins/pi/pi-extensions-adapter.ts | 414 ++++ integrations/yoagent-rutis/tests/pi_test.rs | 272 +++ 11 files changed, 3221 insertions(+), 2 deletions(-) create mode 100644 integrations/yoagent-rutis/examples/pi_extensions.rs create mode 100644 integrations/yoagent-rutis/plugins/pi/fixture-extension.ts create mode 100644 integrations/yoagent-rutis/plugins/pi/package-lock.json create mode 100644 integrations/yoagent-rutis/plugins/pi/package.json create mode 100644 integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts create mode 100644 integrations/yoagent-rutis/tests/pi_test.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28a70d9..aaac624 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -292,6 +292,10 @@ jobs: # dsh-system-prompt and the adapter's other pins, in their own install. - name: Install the dsh npm packages run: npm ci --prefix "$PLUGINS/dsh" + # The pi adapter's end-to-end test (tests/pi_test.rs): pi's own + # packages (its extension loader, TypeBox), in their own install. + - name: Install the pi npm packages + run: npm ci --prefix "$PLUGINS/pi" - name: Install the rutis Python package run: | python -m venv "$PLUGINS/.venv" @@ -307,6 +311,8 @@ jobs: run: cargo test --manifest-path "$MANIFEST" --features node,python,websocket - name: Example run: cargo run --manifest-path "$MANIFEST" --features node,python --example language_plugins + - name: pi extensions example (scripted, the fixture extension) + run: cargo run --manifest-path "$MANIFEST" --features node --example pi_extensions # 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 ed76563..1c91648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `yoagent` are documented here. The format loosely follows [Keep a Changelog](https://keepachangelog.com/), and the project adheres to [Semantic Versioning](https://semver.org/). +## Unreleased + +### yoagent-rutis + +- **pi extensions** (`plugins/pi/pi-extensions-adapter.ts`): the tools and tool policies of [pi](https://github.com/earendil-works/pi) coding-agent extensions, loaded unchanged with pi's own loader, as one yoagent handler. `registerTool` → tools (cancel handle as the signal), `tool_call` → `before_tool` (yoagent's built-ins under pi's names, arguments translated both ways), `tool_result` → `after_tool`, `before_agent_start` additions → a turn note. What does not map (conversation rewriting, session events, commands, UI) is reported, or fails loading with `strict`. Example `pi_extensions` (any extension files; `--live` with DeepSeek), test `pi_test`. + ## 0.25.0 (2026-10-08) ### Added diff --git a/CLAUDE.md b/CLAUDE.md index fa62093..5544b64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; self-checks). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/` and `plugins/dsh/`, clippy per feature, docs, tests with the runtimes required, the `language_plugins` example; `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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.0; ~185 MB installed): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files), `cwd`, `name` (default `pi-extensions`), `toolNames`, `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` and `/.pi`), then drives `Extension.handlers`/`.tools` itself: `registerTool` → `tools`/`call_tool` (exposure `direct`/`model-only` only, latest registration wins, read per run so `session_start` registrations count; `execute(id, params, signal, undefined, ctx)`, throw or `isError` → error), `tool_call` → `before_tool` (names via `TOOL_NAMES` yoagent→pi: `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit`, `search`→`grep`, `list_files`→`find`; `ARGS` translates `edit` (`old_text`/`new_text` ↔ one `edits` entry; more than one → deny) and `grep` (`include` ↔ `glob`); `{block}` → deny, a throw → deny (as pi), in-place `event.input` change → `{args}` by JSON comparison), `tool_result` → `after_tool` (chained, a throw is logged and skipped, as pi), `before_agent_start` → `before_model` once per run (memoized by `run_id`, cleared in `finish`; `event.systemPrompt` is a NUL-delimited placeholder, the text around it becomes the note; a result without the placeholder, a `message`, or setting `systemPromptOptions` (a throwing Proxy) fails), `session_start` fired before `register`, `session_shutdown` on unload. Unmapped events/commands/shortcuts/flags/renderers → one `console.warn` (or a load error with `strict`). Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`), `hasUI` false, `mode` `print`, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact`/`executeTool` throw. `fixture-extension.ts` (pi_echo/pi_fail/pi_slow (writes `/slow.txt`)/pi_dynamic, protected `.env`, `echo original` → `echo rewritten`, `edit` upper-cases `newText`, `SECRET` redaction, a prompt addition, a command). `tests/pi_test.rs` (`required-features = ["node"]`, skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks; scripted write to `.env` + `echo sudo rm -rf build`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/`, 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`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/integrations/yoagent-rutis/Cargo.toml b/integrations/yoagent-rutis/Cargo.toml index 4583c35..730a92e 100644 --- a/integrations/yoagent-rutis/Cargo.toml +++ b/integrations/yoagent-rutis/Cargo.toml @@ -72,6 +72,10 @@ required-features = ["node", "python"] name = "dsh_test" required-features = ["node"] +[[test]] +name = "pi_test" +required-features = ["node"] + [[example]] name = "language_plugins" required-features = ["node", "python"] @@ -79,3 +83,7 @@ required-features = ["node", "python"] [[example]] name = "dsh_tools" required-features = ["node"] + +[[example]] +name = "pi_extensions" +required-features = ["node"] diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 975b21b..0f191ff 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -253,7 +253,7 @@ sits inside every run of every agent using the extension: The end-to-end tests cover local Node and Python runtimes; a remote node over `wss` is not tested here. -## Tools from other rutis ecosystems +## Tools from other agent ecosystems ### dsh (DeepSeek Harness) tool plugins @@ -310,6 +310,56 @@ cargo run --manifest-path examples/rutis-agent-tools/Cargo.toml [-- --live] > tag (`[patch.crates-io]`), leaving one `rutis` in the graph (`cargo tree -d` > shows none twice). Move the tag and the patch together. +### pi extensions (tools and tool policies) + +[pi](https://github.com/earendil-works/pi) extensions are TypeScript +modules written against pi's `ExtensionAPI`. +[`plugins/pi/pi-extensions-adapter.ts`](plugins/pi/pi-extensions-adapter.ts) +loads them unchanged with pi's own loader (pi's packages are installed in +`plugins/pi/`, so their imports — TypeBox, `defineTool`, pi's helpers — are +the real ones) and maps the part of the API that belongs to an agent loop +onto one handler: + +| pi | yoagent | +|---|---| +| `pi.registerTool` | a tool; `execute` gets the bridge's cancel handle as its signal; a throw or `isError` is an error result; tools registered at `session_start` are offered too | +| `on("tool_call")` | `before_tool` for every call, yoagent's built-ins under pi's names (`bash`, `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit` with `edits`, `search`→`grep`, `list_files`→`find`; config `toolNames` overrides): `{ block }` denies, in-place changes to `event.input` rewrite the arguments, a throwing handler blocks | +| `on("tool_result")` | `after_tool`: content, details and isError edits, chained | +| `on("before_agent_start")` | `before_model`, once per run: text added around `event.systemPrompt` becomes a note on the latest user turn; replacing the prompt or returning `message` fails the hook | +| `on("session_start")` / `on("session_shutdown")` | when the adapter loads / unloads | + +The rest does not map and is reported when an extension registers it (a +warning; config `strict: true` makes it a load failure): events that rewrite +or continue the conversation (`context`, `message_end`, `turn_end`, ...) or +steer pi's session tree, and commands, shortcuts, flags and renderers, which +belong to a host app. Runtime actions (`pi.sendMessage`, ...) throw pi's own +"not initialized" error. There is no UI: `ctx.hasUI` is false and `ctx.ui` +behaves as in pi's print mode (`confirm` answers false), so a policy that +would ask the user denies. + +Config: `extensions` (files, loaded in order), `cwd` (the project the +extensions see; default the runtime's), `name`, `toolNames`, `strict`. Load +it as a row of a Node runtime whose `package.json` is `plugins/pi/`'s +(pi 1.1.0, pinned exactly: the adapter imports pi's loader by file, since the +package exports only the variant that also loads `~/.pi`), with `yoagent` +shared in the loader's catalog. + +```sh +(cd plugins/pi && npm ci) +cargo run --features node --example pi_extensions # the fixture extension, scripted +cargo run --features node --example pi_extensions -- hello.ts todo.ts ... # your pi extensions +cargo run --features node --example pi_extensions -- --live --prompt "..." EXT.ts ... # DeepSeek +``` + +Tried with eleven of pi's own examples, unchanged (`hello`, `todo`, +`protected-paths`, `permission-gate`, `tool-override`, `truncated-tool`, +`claude-rules`, `dynamic-tools`, `pirate`, `dirty-repo-guard`, +`confirm-destructive`): all load; their tools run and their tool policies +judge yoagent's own tools (live with DeepSeek, too); the commands and +session events they also register are reported as unavailable. +`tests/pi_test.rs` covers the adapter offline with a fixture extension +(`plugins/pi/fixture-extension.ts`). + ## Semantics ### Runs and plugin lifecycles diff --git a/integrations/yoagent-rutis/examples/pi_extensions.rs b/integrations/yoagent-rutis/examples/pi_extensions.rs new file mode 100644 index 0000000..1e51f89 --- /dev/null +++ b/integrations/yoagent-rutis/examples/pi_extensions.rs @@ -0,0 +1,277 @@ +//! pi coding-agent extensions, unchanged, in a yoagent agent: the adapter +//! `plugins/pi/pi-extensions-adapter.ts` loads them with pi's own loader in a +//! rutis Node runtime and registers their tools and tool policies with the +//! bridge. The agent also has yoagent's built-in tools, which the policies +//! judge under pi's names (`write_file` as `write`, `bash` as `bash`, ...). +//! +//! Pass extension files as arguments (default: the adapter's test fixture), +//! e.g. examples from pi's repository (`packages/coding-agent/examples/extensions/`). +//! The project they see (`ctx.cwd`) is a fresh temporary directory. +//! +//! By default the model is scripted (MockProvider): it asks for a write to +//! `.env` and a `sudo` command (only echoed) through yoagent's own tools, so +//! the policies decide, +//! and the example prints the tools offered, the prompt note and each +//! outcome. `--live` gives the agent to DeepSeek instead (`DEEPSEEK_API_KEY`, +//! else the key in `~/.dskey`) with `--prompt ""` (a default asks it to +//! use whatever tools it has). +//! +//! Setup (once): `npm ci` in `plugins/pi/` (Node 24+). +//! +//! Run: `cargo run --manifest-path integrations/yoagent-rutis/Cargo.toml --features node --example pi_extensions [-- [--live] [--prompt TEXT] EXT.ts ...]` + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use rutis::Ctx; +use rutis_bridge::runtime::LocalRuntime; +use rutis_loader::{ + Chain, Layer, LoaderOptions, LoaderPlugin, Patch, RuntimeResolver, RuntimeRowsPlugin, + ServiceCatalog, +}; +use serde_json::json; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use yoagent::provider::mock::{MockResponse, MockToolCall}; +use yoagent::provider::{ + MockProvider, ModelConfig, ProviderError, StreamConfig, StreamEvent, StreamProvider, +}; +use yoagent::tools::default_tools; +use yoagent::{Agent, AgentEvent, AgentMessage, Content, Message}; +use yoagent_rutis::RutisBridge; + +type BoxError = Box; +/// Each request's tool names and latest user turn. +type SeenRequests = Arc, String)>>>; + +fn pi_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins/pi") +} + +fn text_of(content: &[Content]) -> String { + content + .iter() + .filter_map(|c| match c { + Content::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n") +} + +/// The scripted model, keeping each request's tools and latest user turn +/// (where the adapter's note lands). +struct Scripted { + inner: MockProvider, + seen: SeenRequests, +} + +#[async_trait::async_trait] +impl StreamProvider for Scripted { + async fn stream( + &self, + config: StreamConfig, + tx: mpsc::UnboundedSender, + cancel: CancellationToken, + ) -> Result { + let last = config.messages.iter().rev().find_map(|m| match m { + Message::User { content, .. } => Some(text_of(content)), + _ => None, + }); + let tools = config.tools.iter().map(|t| t.name.clone()).collect(); + self.seen + .lock() + .unwrap() + .push((tools, last.unwrap_or_default())); + self.inner.stream(config, tx, cancel).await + } +} + +fn deepseek_key() -> Result { + if let Ok(key) = std::env::var("DEEPSEEK_API_KEY") { + if !key.trim().is_empty() { + return Ok(key.trim().to_string()); + } + } + let path = Path::new(&std::env::var("HOME")?).join(".dskey"); + let key = std::fs::read_to_string(&path) + .map_err(|e| format!("--live needs DEEPSEEK_API_KEY or {}: {e}", path.display()))?; + Ok(key.split_whitespace().collect()) +} + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + let mut live = false; + let mut prompt = "Use the tools you have to do one small useful thing in this project, then say what you did. Be brief.".to_string(); + let mut extensions = Vec::new(); + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--live" => live = true, + "--prompt" => prompt = args.next().ok_or("--prompt needs a value")?, + path => extensions.push(std::fs::canonicalize(path)?), + } + } + let fixture = extensions.is_empty(); + if fixture { + extensions.push(pi_dir().join("fixture-extension.ts")); + } + let runtime = pi_dir().join("node_modules/@arcships/rutis-runtime"); + if !runtime.exists() + || !pi_dir() + .join("node_modules/@earendil-works/pi-coding-agent") + .exists() + { + return Err(format!("run `npm ci` in {} first", pi_dir().display()).into()); + } + let project = tempfile::tempdir()?; + std::fs::write(project.path().join("README.md"), "# demo project\n")?; + + let root = Ctx::root()?; + let bridge = RutisBridge::install(&root)?; + let mut catalog = ServiceCatalog::new(); + catalog.register_shared("yoagent"); + let node = LocalRuntime::node(&runtime, pi_dir().join("package.json")); + let rows = Arc::new(RuntimeResolver::node(node.handle()).with_catalog(&catalog)); + root.plugin(node); + let loader_plugin = LoaderPlugin::new( + Chain::new().with_shared(rows.clone()), + LoaderOptions { + catalog, + ..LoaderOptions::default() + }, + ); + let loader = loader_plugin.handle(); + root.plugin(loader_plugin).await?; + root.plugin(RuntimeRowsPlugin::new(rows)); + + let patches: Vec = serde_json::from_value(json!([{ "insert": [{ + "id": "pi", + "name": pi_dir().join("pi-extensions-adapter.ts"), + "config": { "extensions": extensions, "cwd": project.path() }, + }] }]))?; + let report = loader + .reconcile(vec![Layer::new("app", patches)], None) + .await?; + if !report.failures.is_empty() { + return Err(format!("the adapter failed to load: {report:?}").into()); + } + tokio::time::timeout(Duration::from_secs(60), async { + while bridge.registry().handlers().is_empty() { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .map_err(|_| "the adapter never registered its handler")?; + + let seen = Arc::new(Mutex::new(Vec::new())); + let env_file = project.path().join(".env"); + let mut agent = if live { + Agent::from_config(ModelConfig::deepseek("deepseek-flash", "DeepSeek Flash")) + .with_api_key(deepseek_key()?) + } else { + prompt = "(scripted)".into(); + let script = MockProvider::new(vec![ + MockResponse::ToolCalls(vec![ + MockToolCall { + provider_metadata: None, + name: "write_file".into(), + arguments: json!({"path": env_file, "content": "TOKEN=1"}), + }, + MockToolCall { + provider_metadata: None, + name: "bash".into(), + arguments: // Harmless if a policy lets it through; pi's permission-gate + // flags `sudo`. + json!({"command": "echo sudo rm -rf build"}), + }, + ]), + MockResponse::Text("(scripted) done.".into()), + ]); + Agent::from_provider( + Scripted { + inner: script, + seen: seen.clone(), + }, + ModelConfig::mock(), + ) + } + .with_system_prompt(format!( + "You are a coding agent working in {}. Be brief.", + project.path().display() + )) + .with_tools(default_tools()) + .with_extension(bridge.extension()); + + let (tx, mut rx) = mpsc::unbounded_channel(); + let printer = tokio::spawn(async move { + while let Some(event) = rx.recv().await { + if let AgentEvent::ToolExecutionStart { + tool_name, args, .. + } = &event + { + let args: String = args.to_string().chars().take(200).collect(); + println!("> {tool_name} {args}"); + } + } + }); + tokio::time::timeout( + Duration::from_secs(300), + agent.prompt_with_sender(prompt, tx), + ) + .await + .map_err(|_| "the run did not finish within 300 s")?; + printer.await?; + + if let Some((tools, note)) = seen.lock().unwrap().first() { + println!("tools offered: {}", tools.join(", ")); + if let Some((_, added)) = note.split_once("(scripted)") { + if !added.trim().is_empty() { + println!("note: {}", added.trim()); + } + } + } + for message in agent.messages() { + match message { + AgentMessage::Llm(Message::ToolResult { + tool_name, + content, + is_error, + .. + }) => { + let text: String = text_of(content).chars().take(300).collect(); + let mark = if *is_error { "error" } else { "ok" }; + println!("[{tool_name}: {mark}] {text}"); + } + AgentMessage::Llm(Message::Assistant { + content, + error_message: Some(e), + .. + }) if text_of(content).is_empty() => println!("error: {e}"), + AgentMessage::Llm(Message::Assistant { content, .. }) => { + let text = text_of(content); + if !text.trim().is_empty() { + println!("assistant: {text}"); + } + } + _ => {} + } + } + let written = env_file.exists(); + println!(".env written: {written}"); + let offered = seen.lock().unwrap().first().map(|(t, _)| t.clone()); + let _ = tokio::time::timeout(Duration::from_secs(10), root.shutdown()).await; + + // Scripted with the fixture: its tools were offered and its policy held. + if fixture && !live { + if written { + return Err("the fixture's policy should have blocked the write to .env".into()); + } + if !offered.unwrap_or_default().iter().any(|t| t == "pi_echo") { + return Err("the fixture's pi_echo tool was not offered".into()); + } + println!("ok: the fixture's tools were offered and its policy held"); + } + Ok(()) +} diff --git a/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts b/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts new file mode 100644 index 0000000..3118ff6 --- /dev/null +++ b/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts @@ -0,0 +1,96 @@ +// A pi extension for the adapter's tests (`tests/pi_test.rs`): written as +// any pi extension is, against pi's own API, with no knowledge of yoagent. +// No network; the slow tool writes `slow.txt` in `ctx.cwd`. + +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { defineTool, type ExtensionAPI } from '@earendil-works/pi-coding-agent' +import { Type } from 'typebox' + +const echo = defineTool({ + name: 'pi_echo', + label: 'Echo', + description: 'Echo the text back.', + promptGuidelines: ['Use pi_echo to repeat text exactly.'], + parameters: Type.Object({ text: Type.String() }), + async execute(_id, params) { + return { content: [{ type: 'text', text: `pi echo: ${params.text}` }], details: { length: params.text.length } } + }, +}) + +export default function (pi: ExtensionAPI) { + pi.registerTool(echo) + + pi.registerTool({ + name: 'pi_fail', + label: 'Fail', + description: 'Always fails.', + parameters: Type.Object({ why: Type.String() }), + async execute(_id, params) { + throw new Error(`pi failure: ${params.why}`) + }, + }) + + pi.registerTool({ + name: 'pi_slow', + label: 'Slow', + description: 'Waits until cancelled.', + parameters: Type.Object({}), + async execute(_id, _params, signal, _onUpdate, ctx) { + const file = join(ctx.cwd, 'slow.txt') + writeFileSync(file, 'started') + await new Promise((resolve) => { + if (signal?.aborted) return resolve() + signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + writeFileSync(file, `aborted: ${signal?.reason?.name ?? signal?.reason}`) + return { content: [{ type: 'text', text: 'cancelled' }], details: undefined } + }, + }) + + // A tool registered when the session starts (pi's dynamic-tools pattern). + pi.on('session_start', () => { + pi.registerTool({ + name: 'pi_dynamic', + label: 'Dynamic', + description: 'Registered at session start.', + parameters: Type.Object({}), + async execute() { + return { content: [{ type: 'text', text: 'dynamic ok' }], details: undefined } + }, + }) + }) + + // pi's protected-paths pattern: block writes to .env. + pi.on('tool_call', async (event) => { + if ((event.toolName === 'write' || event.toolName === 'edit') && String(event.input.path).includes('.env')) { + return { block: true, reason: `Path "${event.input.path}" is protected` } + } + // Rewrite in place, as pi documents. + if (event.toolName === 'bash' && event.input.command === 'echo original') { + event.input.command = 'echo rewritten' + } + if (event.toolName === 'edit') { + const edits = event.input.edits as { oldText: string; newText: string }[] + edits[0].newText = edits[0].newText.toUpperCase() + } + return undefined + }) + + // Redaction: results are chained edits. + pi.on('tool_result', async (event) => { + const text = event.content.map((block) => (block.type === 'text' ? block.text : '')).join('') + if (text.includes('SECRET')) { + return { content: [{ type: 'text', text: text.replaceAll('SECRET', '[redacted]') }] } + } + return undefined + }) + + // Guidance added to the system prompt. + pi.on('before_agent_start', async (event) => ({ + systemPrompt: `${event.systemPrompt}\n\nFixture rules: answer in one line.`, + })) + + // App-level: reported by the adapter as not available. + pi.registerCommand('fixture', { description: 'A command', handler: async () => {} }) +} diff --git a/integrations/yoagent-rutis/plugins/pi/package-lock.json b/integrations/yoagent-rutis/plugins/pi/package-lock.json new file mode 100644 index 0000000..ff324fc --- /dev/null +++ b/integrations/yoagent-rutis/plugins/pi/package-lock.json @@ -0,0 +1,2072 @@ +{ + "name": "yoagent-rutis-pi", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yoagent-rutis-pi", + "dependencies": { + "@arcships/rutis": "0.7.0", + "@arcships/rutis-runtime": "0.7.0", + "@earendil-works/pi-ai": "1.1.0", + "@earendil-works/pi-coding-agent": "1.1.0", + "@earendil-works/pi-tui": "1.1.0", + "jiti": "2.7.0", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.129.0.tgz", + "integrity": "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@arcships/rutis": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@arcships/rutis/-/rutis-0.7.0.tgz", + "integrity": "sha512-unXRYAGJ71PGHNIBWcCUM9WLZh4JJQaiL8lj7kO9M/0+MMJ8NFQHoSmds1jrbnt9jvkTaY54OvC95sYXWTNXPg==", + "license": "MIT", + "engines": { + "node": ">=24" + } + }, + "node_modules/@arcships/rutis-runtime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@arcships/rutis-runtime/-/rutis-runtime-0.7.0.tgz", + "integrity": "sha512-sII2cwbyqu64229b0BCsynyLyYJZQTxj927tNgBW73ih/adDFI9F3MvDpe4+GE9cQ7CRnKZ/i0RCaUMmqgZTbA==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/cordis": "4.0.4", + "tsx": "^4.20.0", + "typescript": "6.0.3", + "ws": "^8.22.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz", + "integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.35.0", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz", + "integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz", + "integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz", + "integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-login": "^3.972.79", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz", + "integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz", + "integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-ini": "^3.973.17", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz", + "integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz", + "integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/token-providers": "3.1138.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1138.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz", + "integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz", + "integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.35.tgz", + "integrity": "sha512-a8xilRoRaalvSPZdfrs0VY3/BPc0uYhXW56Z/k6nmZ5Vk430LHeowf9APIPq8utM3tMJSNGSSuJMlB9YrPsN+A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.30.tgz", + "integrity": "sha512-B6gvZlcnRBNWraKgyEjKsbhv+VjtbyHmpU7hmtTHzXpDSxHPpCJnYLpglEOOsF+SFne20DbZC5IVC1aNr2Pb4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.54.tgz", + "integrity": "sha512-bhESdru8u8KosziH8jcVta/NM/dNreeRz2+2aU86si5bfBYOiJ6IiqD+9U4URUcU5WJO3Yw+jO3GLDIHlscXMQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz", + "integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz", + "integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@deepseek-ai/cordis": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis/-/cordis-4.0.4.tgz", + "integrity": "sha512-obgyxqWAmFn3Re8kvsuUnyW+ihrz6eJCnJO4fh1cQzDtmPYz/zzVeUkH9R94I0OwSVOocK67Kgakm04j/oQXzg==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/cosmokit": "~1.8.5", + "@standard-schema/spec": "^1.1.0" + }, + "bin": { + "cordis": "bin.js" + }, + "peerDependencies": { + "@deepseek-ai/cordis-plugin-include": "~1.0.9", + "@deepseek-ai/cordis-plugin-loader": "~1.0.5" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis-plugin-include": { + "optional": true + }, + "@deepseek-ai/cordis-plugin-loader": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/cosmokit": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.5.tgz", + "integrity": "sha512-LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A==", + "license": "MIT" + }, + "node_modules/@earendil-works/chord": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-1.1.0.tgz", + "integrity": "sha512-gsHzKfyQ3t0ZIQ3cLBjJ5Vqy1TYnFyruHIV3g3BwmulCDYe5BW4yT9vaJk9QzN6YANILIO75uzuq+Gdxtzz1og==", + "license": "MIT", + "dependencies": { + "esbuild": "0.28.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.1.0.tgz", + "integrity": "sha512-aX1KZomNCPmwYnXa3OivF3VYLJ+WPUkIJlEIZTgwdOZdY/+oToWTQ334WYpQeOz9POpiYFNMJLPwIhXQ4e48kg==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^1.1.0", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.1.0.tgz", + "integrity": "sha512-1T7LAkc/5Bvc0v6w4vAGVdCrli0o/E0pEmYKTnixu95vSFArBjvbhS/G4ZwI0RUePgf0Imcu0VyqlM4EcXxqfw==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.129.0", + "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@earendil-works/pi-telemetry": "^1.1.0", + "@google/genai": "2.21.0", + "@smithy/node-http-handler": "4.12.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "openai": "7.19.0", + "partial-json": "0.1.7", + "typebox": "1.3.27" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-codemode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.1.0.tgz", + "integrity": "sha512-8Asc2AzhoNaXS1snmRFa96OhcWIlFM5k1Kuiz6p2DvIYgi5QHQccRAmDVY9oO92TzWrizpvQpconZGQ7JhlI7Q==", + "license": "MIT", + "dependencies": { + "quickjs-wasi": "3.6.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.1.0.tgz", + "integrity": "sha512-SeEi/4hdcHNgA9UWlefZl7ZZpm3dzi2OoxNjDHsBJ9o298LNOtbL4DGKgitlEj6uCTccvtw6f2hlCkTPVJ2RXg==", + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^1.1.0", + "@earendil-works/pi-agent-core": "^1.1.0", + "@earendil-works/pi-ai": "^1.1.0", + "@earendil-works/pi-codemode": "^1.1.0", + "@earendil-works/pi-mcp": "^1.1.0", + "@earendil-works/pi-tui": "^1.1.0", + "@silvia-odwyer/photon-node": "0.3.4", + "brace-expansion": "5.0.12", + "chalk": "6.0.0", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "grok-mermaid": "0.2.3", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.8", + "jiti": "2.7.0", + "minimatch": "10.2.6", + "proper-lockfile": "4.1.2", + "quickjs-wasi": "3.6.2", + "semver": "7.8.5", + "typebox": "1.3.27", + "undici": "8.10.2", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-mcp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.1.0.tgz", + "integrity": "sha512-xGKwvu3SvVTeoIx8Z7UGoJprB4VK20wKORMhufcWGx61fIauhu3J+UqfB5eeoF1oE/geYBA598K6nh1SdABQkg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.1.0.tgz", + "integrity": "sha512-8gAK05/2pPozZZz6hCLOi8x2vsqzvsmO9gG92kbvuvTPm0qMHaFlaXX98ndXsrOi5pmzha7vNLguuG4+0Rgxjw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.1.0.tgz", + "integrity": "sha512-v7wkS0y2ErZvZkSfemqd9RrBZJ5x6p8Ujsv7NdJj01IXJyFM0kNL6rMdcKcvTe7EVTd6ugvDB9VPZSoaBF9QxQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.11" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@smithy/core": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz", + "integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/chalk": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/grok-mermaid": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.3.tgz", + "integrity": "sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "18.0.11", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz", + "integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/quickjs-wasi": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.6.2.tgz", + "integrity": "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==", + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/integrations/yoagent-rutis/plugins/pi/package.json b/integrations/yoagent-rutis/plugins/pi/package.json new file mode 100644 index 0000000..bc49745 --- /dev/null +++ b/integrations/yoagent-rutis/plugins/pi/package.json @@ -0,0 +1,18 @@ +{ + "name": "yoagent-rutis-pi", + "private": true, + "description": "The pi extensions adapter for yoagent-rutis: pi coding-agent extensions' tools and tool policies as a yoagent handler, in rutis's Node runtime. pi's packages are installed so extensions' imports (helpers, TypeBox) resolve to the real thing.", + "type": "module", + "engines": { + "node": ">=24" + }, + "dependencies": { + "@arcships/rutis": "0.7.0", + "@arcships/rutis-runtime": "0.7.0", + "@earendil-works/pi-ai": "1.1.0", + "@earendil-works/pi-coding-agent": "1.1.0", + "@earendil-works/pi-tui": "1.1.0", + "jiti": "2.7.0", + "typebox": "1.3.27" + } +} diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts new file mode 100644 index 0000000..8d588cc --- /dev/null +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -0,0 +1,414 @@ +// pi extensions in yoagent: the tools and tool policies of pi coding-agent +// extensions (https://github.com/earendil-works/pi), offered to yoagent +// agents through the yoagent-rutis bridge. +// +// pi extensions are TypeScript modules written against pi's `ExtensionAPI`. +// This adapter loads them with pi's own loader (so their imports — TypeBox, +// `defineTool`, pi's helpers — resolve to pi's real packages, installed +// here), then maps the part of the API that belongs to an agent loop onto +// one yoagent handler. Load it as a rutis-loader row of a Node runtime whose +// package.json is this directory's, with `yoagent` shared in the loader's +// catalog, and list the extension files in its config: +// +// { "name": "", "config": { "extensions": ["./my-ext.ts"], "cwd": "/repo" } } +// +// What maps, per pi API: +// pi.registerTool a yoagent tool (TypeBox schemas are JSON Schema); +// `execute` gets the bridge's cancel handle as its +// signal; a throw or `isError` is an error result. +// Tools registered later (in `session_start`, say) +// are offered from the next run on. +// on("tool_call") `before_tool`, for every call of the run (the +// agent's own tools too, under pi's names: see +// TOOL_NAMES): `{ block }` denies with its reason; +// changes to `event.input` rewrite the arguments. +// A handler that throws blocks, as in pi. +// on("tool_result") `after_tool`: changes to content, details and +// isError are chained, then applied. +// on("before_agent_start") `before_model`, once per run: text a handler +// adds around `event.systemPrompt` becomes a note +// on the request's latest user turn (yoagent never +// rewrites the system prompt — the prompt cache +// depends on it). Replacing the prompt outright, or +// returning `message`, fails the hook. +// on("session_start") fired once, when the adapter starts; +// on("session_shutdown") when it unloads. +// +// What does not map, and is reported when an extension registers it (a +// warning, or a load failure with `strict: true`): every other event — +// `context` and `message_end` rewrite the conversation, the boundary events +// continue it, the session events steer pi's session tree — and commands, +// shortcuts, flags and renderers, which belong to the host app. Runtime +// actions (`pi.sendMessage`, `pi.setActiveTools`, ...) throw pi's own "not +// initialized" error. `ctx.hasUI` is false and `ctx.ui` behaves as in pi's +// print mode: `confirm` answers false, `select` and `input` nothing, so a +// policy that would ask the user denies instead. + +import { definePlugin } from '@arcships/rutis' +import type { Cancellable, ToolCall, ToolOutput, ToolResult, ToolSpec, Yoagent } from '../yoagent.d.ts' + +export interface Config { + /** The handler's name in the bridge (unique across the host's plugins). */ + name?: string + /** pi extension files (or directories with an `index.ts`), loaded in order. */ + extensions: string[] + /** The project directory extensions see as `ctx.cwd` (default: the runtime's). */ + cwd?: string + /** yoagent tool name → the pi tool name policies see (merged over TOOL_NAMES). */ + toolNames?: Record + /** Fail loading when an extension registers something that does not map. */ + strict?: boolean +} + +/** + * yoagent's built-in tools under the names pi's own built-ins have, so a pi + * policy written for `write` or `bash` judges yoagent's `write_file` and + * `bash`. Arguments are translated both ways (ARGS). + */ +const TOOL_NAMES: Record = { + bash: 'bash', + read_file: 'read', + write_file: 'write', + edit_file: 'edit', + search: 'grep', + list_files: 'find', +} + +type Args = Record + +/** yoagent ↔ pi argument shapes, where they differ. */ +const ARGS: Record = { + edit: { + toPi: ({ old_text, new_text, ...rest }) => ({ ...rest, edits: [{ oldText: old_text, newText: new_text }] }), + fromPi: ({ edits, ...rest }) => { + const list = edits as { oldText?: unknown; newText?: unknown }[] | undefined + if (!Array.isArray(list) || list.length !== 1) { + throw new Error('a pi extension rewrote `edits` into something other than one edit, which yoagent\'s edit_file cannot run') + } + return { ...rest, old_text: list[0].oldText, new_text: list[0].newText } + }, + }, + grep: { + toPi: ({ include, ...rest }) => (include === undefined ? rest : { ...rest, glob: include }), + fromPi: ({ glob, ...rest }) => (glob === undefined ? rest : { ...rest, include: glob }), + }, +} + +/** What `before_agent_start` handlers see as `event.systemPrompt`. */ +const PROMPT_MARK = '\u0000yoagent-system-prompt\u0000' + +/** The events this adapter fires; registering any other is reported. */ +const MAPPED_EVENTS = new Set(['tool_call', 'tool_result', 'before_agent_start', 'session_start', 'session_shutdown']) + +/** pi's print-mode UI: nothing to show, no one to ask. */ +const NO_UI = new Proxy( + { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + editor: async () => undefined, + custom: async () => undefined, + notify: (message: string, level?: string) => console.warn(`[pi ${level ?? 'info'}] ${message}`), + getEditorText: () => '', + getAllThemes: () => [], + getTheme: () => undefined, + getToolsExpanded: () => false, + setTheme: () => ({ success: false, error: 'UI not available' }), + } as Record, + { + // Every other UI call (setStatus, setWidget, ...) does nothing, as in pi. + get: (target, key) => (key in target ? target[key as string] : () => undefined), + }, +) + +interface PiTool { + name: string + label?: string + description?: string + promptGuidelines?: string[] + parameters: Record + exposure?: string + prepareArguments?: (args: unknown) => unknown + execute(id: string, params: unknown, signal: AbortSignal | undefined, onUpdate: unknown, ctx: unknown): Promise<{ + content?: { type: string; text?: string }[] + details?: unknown + isError?: boolean + }> +} + +interface PiExtension { + path: string + handlers: Map unknown)[]> + tools: Map + commands: Map + flags: Map + shortcuts: Map + messageRenderers: Map + toolRenderers?: unknown[] + entryRenderers?: Map +} + +/** pi's loader, by file: the package exports only the discovering variant, which also loads ~/.pi. */ +async function piLoader(): Promise<{ + loadExtensions(paths: string[], cwd: string): Promise<{ + extensions: PiExtension[] + errors: { path: string; error: string }[] + warnings?: { path: string; warning: string }[] + }> +}> { + const index = import.meta.resolve('@earendil-works/pi-coding-agent') + return import(new URL('./core/extensions/loader.js', index).href) +} + +const short = (path: string) => path.split('/').pop() ?? path + +function text(content: { type: string; text?: string }[] | undefined): string { + return (content ?? []).map((block) => (block.type === 'text' ? (block.text ?? '') : `[${block.type} block]`)).join('\n') +} + +export default definePlugin({ + inject: ['yoagent'], + config: { + type: 'object', + required: ['extensions'], + properties: { + name: { type: 'string' }, + extensions: { type: 'array', items: { type: 'string' } }, + cwd: { type: 'string' }, + toolNames: { type: 'object', additionalProperties: { type: 'string' } }, + strict: { type: 'boolean' }, + }, + }, + async apply(ctx, config) { + const yoagent = ctx.use('yoagent') + const cwd = config.cwd ?? process.cwd() + const names = { ...TOOL_NAMES, ...config.toolNames } + + const { SessionManager } = await import('@earendil-works/pi-coding-agent') + const loaded = await (await piLoader()).loadExtensions(config.extensions, cwd) + if (loaded.errors.length > 0) { + throw new Error(`pi extensions failed to load: ${loaded.errors.map((e) => `${e.path}: ${e.error}`).join('; ')}`) + } + for (const w of loaded.warnings ?? []) console.warn(`[pi] ${short(w.path)}: ${w.warning}`) + const extensions = loaded.extensions + + const unmapped: string[] = [] + for (const ext of extensions) { + const what = [ + ...[...ext.handlers.keys()].filter((e) => !MAPPED_EVENTS.has(e)).map((e) => `event "${e}"`), + ...[...ext.commands.keys()].map((c) => `command /${c}`), + ...[...ext.shortcuts.keys()].map((s) => `shortcut ${s}`), + ...[...ext.flags.keys()].map((f) => `flag --${f}`), + ...(ext.messageRenderers.size + (ext.toolRenderers?.length ?? 0) + (ext.entryRenderers?.size ?? 0) > 0 + ? ['renderers'] + : []), + ] + if (what.length > 0) unmapped.push(`${short(ext.path)}: ${what.join(', ')}`) + } + if (unmapped.length > 0) { + const message = `not available in yoagent, ignored: ${unmapped.join('; ')}` + if (config.strict) throw new Error(`pi extensions use what does not map — ${message}`) + console.warn(`[pi] ${message}`) + } + + const sessionManager = SessionManager.inMemory(cwd) + const context = (signal?: AbortSignal) => ({ + ui: NO_UI, + mode: 'print', + hasUI: false, + cwd, + sessionManager, + modelRegistry: undefined, + model: undefined, + scopedModels: [], + signal, + isIdle: () => signal === undefined, + isProjectTrusted: () => false, + hasPendingMessages: () => false, + getContextUsage: () => undefined, + getSystemPrompt: () => '', + abort: () => { + throw new Error('ctx.abort() is not available in yoagent') + }, + shutdown: () => { + throw new Error('ctx.shutdown() is not available in yoagent') + }, + compact: () => { + throw new Error('ctx.compact() is not available in yoagent') + }, + }) + const toolContext = (signal: AbortSignal) => ({ + ...context(signal), + tools: [], + executeTool: async () => { + throw new Error('ctx.executeTool() is not available in yoagent') + }, + }) + + /** Every handler of `event`, in extension load and registration order. */ + const handlers = (event: string) => + extensions.flatMap((ext) => (ext.handlers.get(event) ?? []).map((fn) => ({ ext, fn }))) + + const fire = async (event: string, payload: unknown) => { + for (const { ext, fn } of handlers(event)) { + try { + await fn(payload, context()) + } catch (error) { + console.warn(`[pi] ${short(ext.path)} ${event}: ${error}`) + } + } + } + + /** The tools to offer: every registered tool the model is meant to see, the latest registration of a name winning. */ + const tools = () => { + const byName = new Map() + for (const ext of extensions) { + for (const { definition } of ext.tools.values()) { + const exposure = definition.exposure ?? 'direct' + if (exposure === 'direct' || exposure === 'model-only') byName.set(definition.name, definition) + else byName.delete(definition.name) + } + } + return byName + } + + // Notes from `before_agent_start`, computed once per run. + const notes = new Map>() + + const startNote = async (run: string, prompt: string, signal: AbortSignal) => { + const before = handlers('before_agent_start') + if (before.length === 0) return undefined + let systemPrompt = PROMPT_MARK + const options = new Proxy({} as Record, { + set: () => { + throw new Error('changing systemPromptOptions is not available in yoagent; add text to systemPrompt instead') + }, + }) + for (const { ext, fn } of before) { + const event = { type: 'before_agent_start', prompt, systemPrompt, systemPromptOptions: options } + const result = (await fn(event, context(signal))) as { systemPrompt?: string; message?: unknown } | undefined + if (result?.message !== undefined) { + throw new Error(`${short(ext.path)}: before_agent_start returned a message, which yoagent cannot inject`) + } + if (result?.systemPrompt !== undefined) { + if (!result.systemPrompt.includes(PROMPT_MARK)) { + throw new Error( + `${short(ext.path)}: before_agent_start replaced the system prompt; yoagent only takes text added to it`, + ) + } + systemPrompt = result.systemPrompt + } + } + const added = systemPrompt.split(PROMPT_MARK).map((part) => part.trim()).filter(Boolean) + return added.length > 0 ? added.join('\n\n') : undefined + } + + // Before registering: tools an extension adds at session start are offered from the first run. + await fire('session_start', { type: 'session_start', reason: 'startup' }) + ctx.effect(() => fire('session_shutdown', { type: 'session_shutdown', reason: 'quit' })) + + ctx.effect( + yoagent.register(config.name ?? 'pi-extensions', { + async tools(): Promise { + return [...tools().values()].map((tool) => ({ + name: tool.name, + label: tool.label ?? null, + description: [tool.description ?? '', ...(tool.promptGuidelines ?? []).map((g) => `- ${g}`)] + .filter(Boolean) + .join('\n'), + parameters: tool.parameters, + })) + }, + + async call_tool(call: ToolCall & Cancellable): Promise { + const tool = tools().get(call.tool) + if (!tool) return { text: `pi tool ${call.tool} is no longer registered`, is_error: true } + const params = tool.prepareArguments ? tool.prepareArguments(call.args) : call.args + try { + const out = await tool.execute(call.call_id, params, call.signal, undefined, toolContext(call.signal)) + return { text: text(out.content), details: out.details ?? null, is_error: out.isError === true } + } catch (error) { + return { text: error instanceof Error ? error.message : String(error), is_error: true } + } + }, + + async before_tool(call: ToolCall & Cancellable) { + const policies = handlers('tool_call') + if (policies.length === 0) return + const toolName = names[call.tool] ?? call.tool + const shape = ARGS[toolName] + const original = shape ? shape.toPi(call.args) : call.args + const input = structuredClone(original) + for (const { ext, fn } of policies) { + const event = { type: 'tool_call', toolCallId: call.call_id, toolName, input } + let result: { block?: boolean; reason?: string } | undefined + try { + result = (await fn(event, context(call.signal))) as typeof result + } catch (error) { + // As in pi: a failing tool_call handler blocks the call. + return { deny: `pi extension ${short(ext.path)} failed: ${error}` } + } + if (result?.block) return { deny: result.reason ?? `blocked by pi extension ${short(ext.path)}` } + } + if (JSON.stringify(input) === JSON.stringify(original)) return + try { + return { args: shape ? shape.fromPi(input) : input } + } catch (error) { + return { deny: String(error) } + } + }, + + async after_tool(call: ToolCall & Cancellable, output: ToolOutput) { + const editors = handlers('tool_result') + if (editors.length === 0) return + const toolName = names[call.tool] ?? call.tool + const shape = ARGS[toolName] + const event = { + type: 'tool_result', + toolCallId: call.call_id, + toolName, + input: shape ? shape.toPi(call.args) : call.args, + content: [{ type: 'text', text: output.text }], + details: output.details, + isError: output.is_error, + } + let changed = false + for (const { ext, fn } of editors) { + try { + const result = (await fn(event, context(call.signal))) as + | { content?: { type: string; text?: string }[]; details?: unknown; isError?: boolean } + | undefined + if (!result) continue + if (result.content !== undefined) event.content = result.content as typeof event.content + if (result.details !== undefined) event.details = result.details + if (result.isError !== undefined) event.isError = result.isError + changed = true + } catch (error) { + // As in pi: a failing tool_result handler is reported, the chain goes on. + console.warn(`[pi] ${short(ext.path)} tool_result: ${error}`) + } + } + if (!changed) return + return { text: text(event.content), details: event.details ?? null, is_error: event.isError } + }, + + async before_model(turn) { + let note = notes.get(turn.run_id) + if (!note) { + note = startNote(turn.run_id, turn.latest_user_text ?? '', turn.signal) + notes.set(turn.run_id, note) + // A failed attempt is not cached: the next request asks again. + note.catch(() => notes.delete(turn.run_id)) + } + const text = await note + return text ? { note: text } : undefined + }, + + async finish(outcome) { + notes.delete(outcome.run_id) + }, + }), + ) + + }, +}) diff --git a/integrations/yoagent-rutis/tests/pi_test.rs b/integrations/yoagent-rutis/tests/pi_test.rs new file mode 100644 index 0000000..82ad9f9 --- /dev/null +++ b/integrations/yoagent-rutis/tests/pi_test.rs @@ -0,0 +1,272 @@ +//! pi coding-agent extensions in yoagent, end to end: the adapter +//! `plugins/pi/pi-extensions-adapter.ts` loading a fixture pi extension +//! (`plugins/pi/fixture-extension.ts`, no network) as a rutis-loader row of a +//! Node runtime. +//! +//! Needs Node 24+ and `npm ci` in `plugins/pi/`. Without them the test +//! prints `SKIPPED:` and passes; `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1` (CI) makes +//! it fail instead. +#![cfg(unix)] + +mod common; + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::time::Duration; + +use common::*; +use rutis::Ctx; +use rutis_bridge::runtime::LocalRuntime; +use rutis_loader::{ + Chain, Layer, Loader, LoaderOptions, LoaderPlugin, Patch, RuntimeResolver, RuntimeRowsPlugin, + ServiceCatalog, +}; +use serde_json::{json, Value}; +use yoagent::provider::mock::{MockResponse, MockToolCall}; +use yoagent::tools::{BashTool, EditFileTool, WriteFileTool}; +use yoagent_rutis::RutisBridge; + +fn pi_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins/pi") +} + +/// The Node runtime package, if Node 24+ and `npm ci` in `plugins/pi/` are there. +fn node_runtime() -> Option { + let skip = |why: String| -> Option { + if std::env::var_os("YOAGENT_RUTIS_REQUIRE_RUNTIMES").is_some_and(|v| v == "1") { + panic!("the pi runtime is required (YOAGENT_RUTIS_REQUIRE_RUNTIMES=1) but unavailable: {why}"); + } + eprintln!("SKIPPED: the pi runtime is unavailable: {why}"); + None + }; + let major = Command::new("node") + .arg("--version") + .output() + .ok() + .filter(|out| out.status.success()) + .and_then(|out| { + String::from_utf8_lossy(&out.stdout) + .trim() + .trim_start_matches('v') + .split('.') + .next() + .and_then(|m| m.parse::().ok()) + }); + match major { + Some(major) if major >= 24 => {} + Some(major) => return skip(format!("Node {major} found, 24+ needed")), + None => return skip("no `node` on PATH".into()), + } + let modules = pi_dir().join("node_modules"); + for package in ["@arcships/rutis-runtime", "@earendil-works/pi-coding-agent"] { + if !modules.join(package).join("package.json").exists() { + return skip(format!( + "{package} missing from {}: run `npm ci` in plugins/pi/", + modules.display() + )); + } + } + Some(modules.join("@arcships/rutis-runtime")) +} + +struct Host { + root: Ctx, + bridge: RutisBridge, + loader: Loader, +} + +async fn host(runtime: &Path) -> Host { + let root = Ctx::root().unwrap(); + let bridge = RutisBridge::install(&root).unwrap(); + let mut catalog = ServiceCatalog::new(); + catalog.register_shared("yoagent"); + let node = LocalRuntime::node(runtime, pi_dir().join("package.json")); + let resolver = Arc::new(RuntimeResolver::node(node.handle()).with_catalog(&catalog)); + root.plugin(node); + let plugin = LoaderPlugin::new( + Chain::new().with_shared(resolver.clone()), + LoaderOptions { + catalog, + ..LoaderOptions::default() + }, + ); + let loader = plugin.handle(); + root.plugin(plugin).await.unwrap(); + root.plugin(RuntimeRowsPlugin::new(resolver)); + Host { + root, + bridge, + loader, + } +} + +impl Host { + async fn load(&self, rows: Value) { + let patches: Vec = serde_json::from_value(json!([{ "insert": rows }])).unwrap(); + let report = self + .loader + .reconcile(vec![Layer::new("rows", patches)], None) + .await + .unwrap(); + assert!(report.failures.is_empty(), "{report:?}"); + let registry = self.bridge.registry().clone(); + tokio::time::timeout(Duration::from_secs(60), async { + while !registry + .handlers() + .iter() + .any(|h| h.name() == "pi-extensions") + { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("the adapter registers its handler"); + } +} + +fn calls(names: &[(&str, Value)]) -> MockResponse { + MockResponse::ToolCalls( + names + .iter() + .map(|(name, args)| MockToolCall { + provider_metadata: None, + name: (*name).into(), + arguments: args.clone(), + }) + .collect(), + ) +} + +async fn wait_file(path: &Path, want: impl Fn(&str) -> bool) -> String { + tokio::time::timeout(Duration::from_secs(30), async { + loop { + if let Ok(text) = std::fs::read_to_string(path) { + if want(&text) { + return text; + } + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "{} never matched: {:?}", + path.display(), + std::fs::read_to_string(path) + ) + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn pi_extensions_reach_a_yoagent_agent_through_the_adapter() { + let Some(runtime) = node_runtime() else { + return; + }; + let project = tempfile::tempdir().unwrap(); + let env_file = project.path().join(".env"); + let notes = project.path().join("notes.txt"); + std::fs::write(¬es, "hello world").unwrap(); + + let host = host(&runtime).await; + host.load(json!([{ + "id": "pi", + "name": pi_dir().join("pi-extensions-adapter.ts"), + "config": { + "extensions": [pi_dir().join("fixture-extension.ts")], + "cwd": project.path(), + }, + }])) + .await; + + let (agent, seen) = agent(vec![ + calls(&[ + ("pi_echo", json!({"text": "SECRET hi"})), + ("pi_fail", json!({"why": "nope"})), + ("pi_dynamic", json!({})), + ( + "write_file", + json!({"path": env_file, "content": "TOKEN=1"}), + ), + ("bash", json!({"command": "echo original"})), + ( + "edit_file", + json!({"path": notes, "old_text": "world", "new_text": "pi"}), + ), + ]), + text("done"), + call("pi_slow", json!({})), + text("never sent"), + ]); + let mut agent = agent + .with_tools(vec![ + Box::new(BashTool::new()), + Box::new(WriteFileTool::new()), + Box::new(EditFileTool::new()), + ]) + .with_extension(host.bridge.extension()); + let (_, results) = tokio::time::timeout(Duration::from_secs(60), run(&mut agent, "go")) + .await + .expect("the run finishes"); + + // The extension's tools are offered, the one registered at session start too... + let seen_now = seen.lock().unwrap().clone(); + for tool in ["pi_echo", "pi_fail", "pi_slow", "pi_dynamic"] { + assert!( + seen_now[0].tools.contains(&tool.to_string()), + "{seen_now:?}" + ); + } + let result = |name: &str| { + results + .iter() + .find(|(n, _, _)| n == name) + .unwrap_or_else(|| panic!("no {name} result: {results:?}")) + .clone() + }; + // ...a tool's text comes back, through the extension's tool_result redaction... + assert_eq!( + result("pi_echo"), + ("pi_echo".into(), "pi echo: [redacted] hi".into(), false) + ); + // ...a throwing tool is an error result... + let (_, text, is_error) = result("pi_fail"); + assert!(is_error && text.contains("pi failure: nope"), "{results:?}"); + assert_eq!(result("pi_dynamic").1, "dynamic ok"); + // ...yoagent's own write_file is judged as pi's `write` and blocked... + let (_, text, is_error) = result("write_file"); + assert!(is_error && text.contains("is protected"), "{results:?}"); + assert!(!env_file.exists(), "the blocked write never ran"); + // ...an in-place rewrite of `event.input` changes the call... + let (_, text, is_error) = result("bash"); + assert!(!is_error && text.contains("rewritten"), "{results:?}"); + // ...including edit_file's arguments, translated to pi's `edits` and back. + assert!(!result("edit_file").2, "{results:?}"); + assert_eq!(std::fs::read_to_string(¬es).unwrap(), "hello PI"); + // before_agent_start's addition to the system prompt arrives as a note, never stored. + let note = &seen_now[0].last_user; + assert!( + note.contains("Fixture rules: answer in one line."), + "{note}" + ); + assert!( + !format!("{:?}", agent.messages()).contains("Fixture rules"), + "the note is never stored" + ); + + // Cancelling the run aborts the pi tool through its signal. + let mut rx = agent.prompt("slow").await; + let slow = project.path().join("slow.txt"); + wait_file(&slow, |t| t == "started").await; + agent.abort(); + tokio::time::timeout(Duration::from_secs(30), async { + while rx.recv().await.is_some() {} + }) + .await + .expect("the cancelled run ends"); + agent.finish().await; + let ended = wait_file(&slow, |t| t != "started").await; + assert_eq!(ended, "aborted: AbortError"); + host.root.shutdown().await.unwrap(); +} From 10eedb1ef0906d05c7289b718c9e707fac00681b Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 11:00:28 +0200 Subject: [PATCH 02/15] =?UTF-8?q?fix(rutis):=20pi=20adapter=20review=20fin?= =?UTF-8?q?dings=20=E2=80=94=20pi=20semantics=20on=20the=20less=20common?= =?UTF-8?q?=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - translate arguments only for yoagent built-ins (a pi tool named edit/grep keeps its own), and enforce an extension's override of a built-in - prepareArguments + pi's validation before the tool_call policies - after_tool: real content (images kept), only the fields a handler set - before_agent_start: each failing handler skipped alone, cached per run - first registration wins, defaultActive respected (pi's activation) - providers/MCP servers reported; theme proxy; getSystemPrompt in the hook; search ignoreCase, find pattern - example: tools act in the temp project, --without NAME - tests: fixture-extra.ts, less-common-paths and strict tests Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- integrations/yoagent-rutis/README.md | 55 ++-- .../yoagent-rutis/examples/pi_extensions.rs | 20 +- .../plugins/pi/fixture-extension.ts | 5 + .../yoagent-rutis/plugins/pi/fixture-extra.ts | 78 +++++ .../plugins/pi/pi-extensions-adapter.ts | 294 ++++++++++++------ integrations/yoagent-rutis/tests/pi_test.rs | 161 +++++++++- 8 files changed, 500 insertions(+), 117 deletions(-) create mode 100644 integrations/yoagent-rutis/plugins/pi/fixture-extra.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c91648..beab026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ adheres to [Semantic Versioning](https://semver.org/). ### yoagent-rutis -- **pi extensions** (`plugins/pi/pi-extensions-adapter.ts`): the tools and tool policies of [pi](https://github.com/earendil-works/pi) coding-agent extensions, loaded unchanged with pi's own loader, as one yoagent handler. `registerTool` → tools (cancel handle as the signal), `tool_call` → `before_tool` (yoagent's built-ins under pi's names, arguments translated both ways), `tool_result` → `after_tool`, `before_agent_start` additions → a turn note. What does not map (conversation rewriting, session events, commands, UI) is reported, or fails loading with `strict`. Example `pi_extensions` (any extension files; `--live` with DeepSeek), test `pi_test`. +- **pi extensions** (`plugins/pi/pi-extensions-adapter.ts`): the tools and tool policies of [pi](https://github.com/earendil-works/pi) coding-agent extensions, loaded unchanged with pi's own loader, as one yoagent handler following pi 1.1.0's semantics. `registerTool` → tools (pi's activation and first-registration-wins, `prepareArguments` and validation before policies, cancel handle as the signal), `tool_call` → `before_tool` (yoagent's built-ins under pi's names, arguments translated both ways; an extension's override of a built-in is enforced), `tool_result` → `after_tool`, `before_agent_start` additions → a turn note (a failing handler skipped alone). What does not map (conversation rewriting, session events, commands, UI, providers, MCP servers) is reported, or fails loading with `strict`. Example `pi_extensions` (any extension files; `--live` with DeepSeek; `--without`), test `pi_test`. ## 0.25.0 (2026-10-08) diff --git a/CLAUDE.md b/CLAUDE.md index 5544b64..36e2699 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.0; ~185 MB installed): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files), `cwd`, `name` (default `pi-extensions`), `toolNames`, `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` and `/.pi`), then drives `Extension.handlers`/`.tools` itself: `registerTool` → `tools`/`call_tool` (exposure `direct`/`model-only` only, latest registration wins, read per run so `session_start` registrations count; `execute(id, params, signal, undefined, ctx)`, throw or `isError` → error), `tool_call` → `before_tool` (names via `TOOL_NAMES` yoagent→pi: `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit`, `search`→`grep`, `list_files`→`find`; `ARGS` translates `edit` (`old_text`/`new_text` ↔ one `edits` entry; more than one → deny) and `grep` (`include` ↔ `glob`); `{block}` → deny, a throw → deny (as pi), in-place `event.input` change → `{args}` by JSON comparison), `tool_result` → `after_tool` (chained, a throw is logged and skipped, as pi), `before_agent_start` → `before_model` once per run (memoized by `run_id`, cleared in `finish`; `event.systemPrompt` is a NUL-delimited placeholder, the text around it becomes the note; a result without the placeholder, a `message`, or setting `systemPromptOptions` (a throwing Proxy) fails), `session_start` fired before `register`, `session_shutdown` on unload. Unmapped events/commands/shortcuts/flags/renderers → one `console.warn` (or a load error with `strict`). Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`), `hasUI` false, `mode` `print`, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact`/`executeTool` throw. `fixture-extension.ts` (pi_echo/pi_fail/pi_slow (writes `/slow.txt`)/pi_dynamic, protected `.env`, `echo original` → `echo rewritten`, `edit` upper-cases `newText`, `SECRET` redaction, a prompt addition, a command). `tests/pi_test.rs` (`required-features = ["node"]`, skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks; scripted write to `.env` + `echo sudo rm -rf build`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/`, 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.0; ~185 MB installed): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files), `cwd`, `name` (default `pi-extensions`), `toolNames`, `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` and `/.pi`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop: tools = `registered()` (first registration of a name wins, pi's `getAllRegisteredTools`) filtered to `offered()` (exposure `direct`/`model-only`, `defaultActive !== false`), read per run so `session_start` registrations count; `before_tool` for a pi tool runs `prepareArguments` then pi-ai's `validateToolArguments` (a throw → deny) **before** the `tool_call` policies, which see that object, and returns it as `{args}` when it differs from the raw call (`call_tool` validates again, no second prepare); for a yoagent built-in (`TOOL_NAMES` yoagent→pi: `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit`, `search`→`grep`, `list_files`→`find`) `ARGS` — keyed by the **yoagent** name, so a pi tool named `edit` is never translated — maps `edit_file` (`old_text`/`new_text` ↔ one `edits` entry; more → deny), `search` (`include`↔`glob`, `case_sensitive`↔`!ignoreCase`), `list_files` (`pattern` default `*`); a built-in whose pi name an offered pi tool has (`read` overrides `read_file`) is denied ("call X instead"); a pi tool named like a yoagent tool (`bash`) is warned once (fails under `strict`) — yoagent's static tool wins the merge. `{block}`/a throw → deny, in-place `event.input` change → `{args}`. `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, and returns only the fields set (`text` only when content was replaced). `before_agent_start` → `before_model` once per run (memoized by `run_id`, never rejected, cleared in `finish`; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder, the text around it becomes the note; per handler try/catch: a throw, a result without the placeholder, a `message`, or a `systemPromptOptions` write (throwing Proxy) is warned and skipped). `session_start` fired before `register`, `session_shutdown` on unload. Unmapped events/commands/shortcuts/flags/renderers/providers (`runtime.pendingProviderRegistrations`)/MCP servers (`runtime.mcpServers`) → one `console.warn` (or a load error with `strict`). Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `theme` = identity Proxy), `hasUI` false, `mode` `print`, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact`/`executeTool` throw. Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites, `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 with `prepareArguments`, a throwing policy, grep glob rewrite, details-only result edit, three bad `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, three tests incl. `strict`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks; chdirs into the temp project; `--without NAME` drops a yoagent built-in; scripted write to `.env` + `echo sudo rm -rf build`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/`, 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`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 0f191ff..781b54d 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -318,47 +318,60 @@ modules written against pi's `ExtensionAPI`. loads them unchanged with pi's own loader (pi's packages are installed in `plugins/pi/`, so their imports — TypeBox, `defineTool`, pi's helpers — are the real ones) and maps the part of the API that belongs to an agent loop -onto one handler: +onto one handler, following pi 1.1.0's own runner and agent loop: | pi | yoagent | |---|---| -| `pi.registerTool` | a tool; `execute` gets the bridge's cancel handle as its signal; a throw or `isError` is an error result; tools registered at `session_start` are offered too | -| `on("tool_call")` | `before_tool` for every call, yoagent's built-ins under pi's names (`bash`, `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit` with `edits`, `search`→`grep`, `list_files`→`find`; config `toolNames` overrides): `{ block }` denies, in-place changes to `event.input` rewrite the arguments, a throwing handler blocks | -| `on("tool_result")` | `after_tool`: content, details and isError edits, chained | -| `on("before_agent_start")` | `before_model`, once per run: text added around `event.systemPrompt` becomes a note on the latest user turn; replacing the prompt or returning `message` fails the hook | +| `pi.registerTool` | a tool, while pi would activate it (exposure `direct` / `model-only`, `defaultActive` not false; the first registration of a name wins). Arguments go through `prepareArguments` and pi's validation before any policy sees them; `execute` gets the bridge's cancel handle as its signal; a throw or `isError` is an error result. Tools registered at `session_start` are offered too | +| `on("tool_call")` | `before_tool` for every call, yoagent's built-ins under pi's names (`bash`, `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit` with `edits`, `search`→`grep` with `glob`/`ignoreCase`, `list_files`→`find`; config `toolNames` overrides): `{ block }` denies, in-place changes to `event.input` rewrite the arguments, a throwing handler blocks | +| `on("tool_result")` | `after_tool`: content, details and isError edits, chained; only the fields a handler set are applied (a replaced content keeps its text); a throwing handler is reported and skipped | +| `on("before_agent_start")` | `before_model`, once per run: text added around `event.systemPrompt` becomes a note on the latest user turn. A handler that throws, replaces the prompt, returns `message` or changes `systemPromptOptions` is reported and skipped; the others still count | | `on("session_start")` / `on("session_shutdown")` | when the adapter loads / unloads | +**Overrides.** An extension tool that replaces one of pi's built-ins under +another name than yoagent's (`read`, `write`, `edit`, `grep`, `find`) makes +the adapter deny yoagent's counterpart (`read_file`, ...), so the model +cannot go around it. One named exactly like a yoagent tool (pi's sandboxed +`bash`) loses to yoagent's when the host installs both: the adapter warns +(fails under `strict`), and the host must leave its own tool out +(`--without bash` in the example). + The rest does not map and is reported when an extension registers it (a warning; config `strict: true` makes it a load failure): events that rewrite or continue the conversation (`context`, `message_end`, `turn_end`, ...) or -steer pi's session tree, and commands, shortcuts, flags and renderers, which -belong to a host app. Runtime actions (`pi.sendMessage`, ...) throw pi's own -"not initialized" error. There is no UI: `ctx.hasUI` is false and `ctx.ui` -behaves as in pi's print mode (`confirm` answers false), so a policy that -would ask the user denies. +steer pi's session tree, commands, shortcuts, flags and renderers (a host +app's), and model providers and MCP servers. Runtime actions +(`pi.sendMessage`, ...) throw pi's own "not initialized" error. There is no +UI: `ctx.hasUI` is false and `ctx.ui` behaves as in pi's print mode +(`confirm` answers false), so a policy that would ask the user denies. Config: `extensions` (files, loaded in order), `cwd` (the project the extensions see; default the runtime's), `name`, `toolNames`, `strict`. Load it as a row of a Node runtime whose `package.json` is `plugins/pi/`'s (pi 1.1.0, pinned exactly: the adapter imports pi's loader by file, since the -package exports only the variant that also loads `~/.pi`), with `yoagent` -shared in the loader's catalog. +package exports only the variant that also loads `~/.pi`; check it on every +pi upgrade), with `yoagent` shared in the loader's catalog. ```sh (cd plugins/pi && npm ci) cargo run --features node --example pi_extensions # the fixture extension, scripted cargo run --features node --example pi_extensions -- hello.ts todo.ts ... # your pi extensions -cargo run --features node --example pi_extensions -- --live --prompt "..." EXT.ts ... # DeepSeek +cargo run --features node --example pi_extensions -- --live --prompt "..." [--without bash] EXT.ts ... # DeepSeek ``` -Tried with eleven of pi's own examples, unchanged (`hello`, `todo`, -`protected-paths`, `permission-gate`, `tool-override`, `truncated-tool`, -`claude-rules`, `dynamic-tools`, `pirate`, `dirty-repo-guard`, -`confirm-destructive`): all load; their tools run and their tool policies -judge yoagent's own tools (live with DeepSeek, too); the commands and -session events they also register are reported as unavailable. -`tests/pi_test.rs` covers the adapter offline with a fixture extension -(`plugins/pi/fixture-extension.ts`). +The example runs yoagent's own tools in the temporary project the +extensions see (it is the process's working directory). + +Tried with eleven of pi's own examples, unchanged, scripted and live with +DeepSeek: `hello`, `todo`, `tool-override` (`read`; yoagent's `read_file` is +then denied), `truncated-tool` (`rg`) and `dynamic-tools` offer working +tools; `protected-paths` and `permission-gate` judge yoagent's own +`write_file` and `bash`; `claude-rules` loads (its note needs a project with +`.claude/rules/`, which these runs did not have). `pirate` only acts after its `/pirate` command, and +`dirty-repo-guard` and `confirm-destructive` only on pi's session events, so +under the adapter they load and do nothing (reported). `tests/pi_test.rs` +covers the adapter offline with two fixture extensions +(`plugins/pi/fixture-extension.ts`, `plugins/pi/fixture-extra.ts`). ## Semantics diff --git a/integrations/yoagent-rutis/examples/pi_extensions.rs b/integrations/yoagent-rutis/examples/pi_extensions.rs index 1e51f89..d43a950 100644 --- a/integrations/yoagent-rutis/examples/pi_extensions.rs +++ b/integrations/yoagent-rutis/examples/pi_extensions.rs @@ -16,9 +16,15 @@ //! else the key in `~/.dskey`) with `--prompt ""` (a default asks it to //! use whatever tools it has). //! +//! The project is also the process's working directory, so yoagent's own +//! tools (`bash`, relative paths) act there, not where `cargo run` started. +//! `--without NAME` leaves a yoagent built-in out (repeatable): use it when +//! an extension replaces one under the same name, e.g. pi's sandboxed +//! `bash` (yoagent's own tool would otherwise win and run unsandboxed). +//! //! Setup (once): `npm ci` in `plugins/pi/` (Node 24+). //! -//! Run: `cargo run --manifest-path integrations/yoagent-rutis/Cargo.toml --features node --example pi_extensions [-- [--live] [--prompt TEXT] EXT.ts ...]` +//! Run: `cargo run --manifest-path integrations/yoagent-rutis/Cargo.toml --features node --example pi_extensions [-- [--live] [--prompt TEXT] [--without NAME] EXT.ts ...]` use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -103,6 +109,7 @@ fn deepseek_key() -> Result { #[tokio::main] async fn main() -> Result<(), BoxError> { let mut live = false; + let mut without = Vec::new(); let mut prompt = "Use the tools you have to do one small useful thing in this project, then say what you did. Be brief.".to_string(); let mut extensions = Vec::new(); let mut args = std::env::args().skip(1); @@ -110,6 +117,7 @@ async fn main() -> Result<(), BoxError> { match arg.as_str() { "--live" => live = true, "--prompt" => prompt = args.next().ok_or("--prompt needs a value")?, + "--without" => without.push(args.next().ok_or("--without needs a tool name")?), path => extensions.push(std::fs::canonicalize(path)?), } } @@ -126,6 +134,9 @@ async fn main() -> Result<(), BoxError> { return Err(format!("run `npm ci` in {} first", pi_dir().display()).into()); } let project = tempfile::tempdir()?; + // Extension paths are canonical already; from here on, relative paths and + // `bash` act in the project the extensions see as `ctx.cwd`. + std::env::set_current_dir(project.path())?; std::fs::write(project.path().join("README.md"), "# demo project\n")?; let root = Ctx::root()?; @@ -201,7 +212,12 @@ async fn main() -> Result<(), BoxError> { "You are a coding agent working in {}. Be brief.", project.path().display() )) - .with_tools(default_tools()) + .with_tools( + default_tools() + .into_iter() + .filter(|tool| !without.iter().any(|name| name == tool.name())) + .collect(), + ) .with_extension(bridge.extension()); let (tx, mut rx) = mpsc::unbounded_channel(); diff --git a/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts b/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts index 3118ff6..43c6fd6 100644 --- a/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts +++ b/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts @@ -91,6 +91,11 @@ export default function (pi: ExtensionAPI) { systemPrompt: `${event.systemPrompt}\n\nFixture rules: answer in one line.`, })) + // When the adapter unloads. + pi.on('session_shutdown', (_event, ctx) => { + writeFileSync(join(ctx.cwd, 'shutdown.txt'), 'bye') + }) + // App-level: reported by the adapter as not available. pi.registerCommand('fixture', { description: 'A command', handler: async () => {} }) } diff --git a/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts new file mode 100644 index 0000000..9dda283 --- /dev/null +++ b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts @@ -0,0 +1,78 @@ +// A second pi extension for `tests/pi_test.rs`, loaded after +// `fixture-extension.ts`: the less common paths. + +import { type ExtensionAPI } from '@earendil-works/pi-coding-agent' +import { Type } from 'typebox' + +export default function (pi: ExtensionAPI) { + // Registered second: pi keeps the first registration of a name. + pi.registerTool({ + name: 'pi_echo', + label: 'Echo (second)', + description: 'Never offered: the first registration wins.', + parameters: Type.Object({ text: Type.String() }), + async execute() { + return { content: [{ type: 'text', text: 'second echo' }], details: undefined } + }, + }) + + // Not activated on registration, so not offered. + pi.registerTool({ + name: 'pi_inactive', + label: 'Inactive', + description: 'defaultActive: false', + defaultActive: false, + parameters: Type.Object({}), + async execute() { + return { content: [{ type: 'text', text: 'inactive ran' }], details: undefined } + }, + }) + + // Returns an error instead of throwing. + pi.registerTool({ + name: 'pi_soft_error', + label: 'Soft error', + description: 'Returns isError.', + parameters: Type.Object({}), + async execute() { + return { content: [{ type: 'text', text: 'soft failure' }], details: undefined, isError: true } + }, + }) + + // Overrides pi's built-in `edit`; accepts `edits` as a JSON string, as pi's own edit does. + pi.registerTool({ + name: 'edit', + label: 'edit (override)', + description: 'Edit a file (override).', + parameters: Type.Object({ + path: Type.String(), + edits: Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() })), + }), + prepareArguments(args: unknown) { + const a = args as { path: string; edits: unknown } + return typeof a.edits === 'string' ? { ...a, edits: JSON.parse(a.edits) } : a + }, + async execute(_id, params) { + return { content: [{ type: 'text', text: `edit override: ${JSON.stringify(params)}` }], details: undefined } + }, + }) + + pi.on('tool_call', async (event) => { + if (event.toolName === 'pi_echo' && event.input.text === 'boom') throw new Error('policy crashed') + // yoagent's search, as pi's grep: its include is pi's glob. + if (event.toolName === 'grep' && event.input.glob === '*.md') event.input.glob = '*.txt' + return undefined + }) + + // A details-only edit must keep the content (images included). + pi.on('tool_result', async (event) => (event.toolName === 'read' ? { details: { seen: true } } : undefined)) + + // Each is skipped on its own; the first fixture's addition still counts. + pi.on('before_agent_start', async () => { + throw new Error('prompt hook crashed') + }) + pi.on('before_agent_start', async () => ({ + message: { customType: 'x', content: 'injected', display: false }, + })) + pi.on('before_agent_start', async (event) => ({ systemPrompt: 'a whole new prompt' })) +} diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts index 8d588cc..6c28557 100644 --- a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -12,37 +12,53 @@ // // { "name": "", "config": { "extensions": ["./my-ext.ts"], "cwd": "/repo" } } // -// What maps, per pi API: -// pi.registerTool a yoagent tool (TypeBox schemas are JSON Schema); -// `execute` gets the bridge's cancel handle as its -// signal; a throw or `isError` is an error result. -// Tools registered later (in `session_start`, say) -// are offered from the next run on. +// What maps, per pi API (following pi 1.1.0's own runner and agent loop): +// pi.registerTool a yoagent tool, offered while pi would activate it +// (exposure `direct` / `model-only`, `defaultActive` +// not false; the first registration of a name wins, +// as in pi). Arguments go through the tool's +// `prepareArguments` and pi's validation before any +// policy sees them; `execute` gets the bridge's +// cancel handle as its signal; a throw or `isError` +// is an error result. Tools registered later (in +// `session_start`, say) are offered from the next +// run on. A tool that overrides one of pi's +// built-ins (`read`, `edit`, ...) makes the adapter +// deny yoagent's counterpart (`read_file`, ...), so +// the model cannot go around it; one named exactly +// like a yoagent tool (`bash`) loses to that tool +// when the host installs it — the host must not. // on("tool_call") `before_tool`, for every call of the run (the -// agent's own tools too, under pi's names: see -// TOOL_NAMES): `{ block }` denies with its reason; -// changes to `event.input` rewrite the arguments. -// A handler that throws blocks, as in pi. +// agent's own built-ins too, under pi's names: see +// TOOL_NAMES, arguments translated both ways): +// `{ block }` denies with its reason; changes to +// `event.input` rewrite the arguments. A handler +// that throws blocks, as in pi. // on("tool_result") `after_tool`: changes to content, details and -// isError are chained, then applied. +// isError are chained, then applied (a replaced +// content keeps only its text). A handler that +// throws is reported and skipped, as in pi. // on("before_agent_start") `before_model`, once per run: text a handler // adds around `event.systemPrompt` becomes a note // on the request's latest user turn (yoagent never // rewrites the system prompt — the prompt cache -// depends on it). Replacing the prompt outright, or -// returning `message`, fails the hook. +// depends on it). A handler that throws, replaces +// the prompt, returns `message` or changes +// `systemPromptOptions` is reported and skipped; +// the others still count. // on("session_start") fired once, when the adapter starts; // on("session_shutdown") when it unloads. // // What does not map, and is reported when an extension registers it (a // warning, or a load failure with `strict: true`): every other event — // `context` and `message_end` rewrite the conversation, the boundary events -// continue it, the session events steer pi's session tree — and commands, -// shortcuts, flags and renderers, which belong to the host app. Runtime -// actions (`pi.sendMessage`, `pi.setActiveTools`, ...) throw pi's own "not -// initialized" error. `ctx.hasUI` is false and `ctx.ui` behaves as in pi's -// print mode: `confirm` answers false, `select` and `input` nothing, so a -// policy that would ask the user denies instead. +// continue it, the session events steer pi's session tree — commands, +// shortcuts, flags and renderers, which belong to the host app, and model +// providers and MCP servers. Runtime actions (`pi.sendMessage`, +// `pi.setActiveTools`, ...) throw pi's own "not initialized" error. +// `ctx.hasUI` is false and `ctx.ui` behaves as in pi's print mode: `confirm` +// answers false, `select` and `input` nothing, so a policy that would ask +// the user denies instead. import { definePlugin } from '@arcships/rutis' import type { Cancellable, ToolCall, ToolOutput, ToolResult, ToolSpec, Yoagent } from '../yoagent.d.ts' @@ -76,21 +92,38 @@ const TOOL_NAMES: Record = { type Args = Record -/** yoagent ↔ pi argument shapes, where they differ. */ +/** + * yoagent ↔ pi argument shapes, where they differ, keyed by the yoagent + * built-in: only those calls are translated (a pi tool that is itself named + * `edit` keeps its arguments as they are). + */ const ARGS: Record = { - edit: { + edit_file: { toPi: ({ old_text, new_text, ...rest }) => ({ ...rest, edits: [{ oldText: old_text, newText: new_text }] }), fromPi: ({ edits, ...rest }) => { const list = edits as { oldText?: unknown; newText?: unknown }[] | undefined if (!Array.isArray(list) || list.length !== 1) { - throw new Error('a pi extension rewrote `edits` into something other than one edit, which yoagent\'s edit_file cannot run') + throw new Error("a pi extension rewrote `edits` into something other than one edit, which yoagent's edit_file cannot run") } return { ...rest, old_text: list[0].oldText, new_text: list[0].newText } }, }, - grep: { - toPi: ({ include, ...rest }) => (include === undefined ? rest : { ...rest, glob: include }), - fromPi: ({ glob, ...rest }) => (glob === undefined ? rest : { ...rest, include: glob }), + search: { + toPi: ({ include, case_sensitive, ...rest }) => ({ + ...rest, + ...(include === undefined ? {} : { glob: include }), + ...(case_sensitive === undefined ? {} : { ignoreCase: !case_sensitive }), + }), + fromPi: ({ glob, ignoreCase, ...rest }) => ({ + ...rest, + ...(glob === undefined ? {} : { include: glob }), + ...(ignoreCase === undefined ? {} : { case_sensitive: !ignoreCase }), + }), + }, + list_files: { + // pi's `find` requires a pattern; yoagent's list_files has an optional one. + toPi: ({ pattern, ...rest }) => ({ ...rest, pattern: pattern ?? '*' }), + fromPi: ({ pattern, ...rest }) => (pattern === '*' ? rest : { ...rest, pattern }), }, } @@ -100,6 +133,11 @@ const PROMPT_MARK = '\u0000yoagent-system-prompt\u0000' /** The events this adapter fires; registering any other is reported. */ const MAPPED_EVENTS = new Set(['tool_call', 'tool_result', 'before_agent_start', 'session_start', 'session_shutdown']) +/** Any theme call returns its text unstyled (`theme.fg('dim', text)` → text). */ +const PLAIN_THEME = new Proxy({} as Record, { + get: () => (...args: unknown[]) => args[args.length - 1], +}) + /** pi's print-mode UI: nothing to show, no one to ask. */ const NO_UI = new Proxy( { @@ -114,6 +152,7 @@ const NO_UI = new Proxy( getTheme: () => undefined, getToolsExpanded: () => false, setTheme: () => ({ success: false, error: 'UI not available' }), + theme: PLAIN_THEME, } as Record, { // Every other UI call (setStatus, setWidget, ...) does nothing, as in pi. @@ -121,6 +160,11 @@ const NO_UI = new Proxy( }, ) +interface Block { + type: string + text?: string +} + interface PiTool { name: string label?: string @@ -128,9 +172,10 @@ interface PiTool { promptGuidelines?: string[] parameters: Record exposure?: string + defaultActive?: boolean prepareArguments?: (args: unknown) => unknown execute(id: string, params: unknown, signal: AbortSignal | undefined, onUpdate: unknown, ctx: unknown): Promise<{ - content?: { type: string; text?: string }[] + content?: Block[] details?: unknown isError?: boolean }> @@ -148,12 +193,18 @@ interface PiExtension { entryRenderers?: Map } +interface PiRuntime { + pendingProviderRegistrations?: { name: string; extensionPath: string }[] + mcpServers?: { list(): { name: string; extensionPath?: string }[] } +} + /** pi's loader, by file: the package exports only the discovering variant, which also loads ~/.pi. */ async function piLoader(): Promise<{ loadExtensions(paths: string[], cwd: string): Promise<{ extensions: PiExtension[] errors: { path: string; error: string }[] warnings?: { path: string; warning: string }[] + runtime: PiRuntime }> }> { const index = import.meta.resolve('@earendil-works/pi-coding-agent') @@ -162,7 +213,7 @@ async function piLoader(): Promise<{ const short = (path: string) => path.split('/').pop() ?? path -function text(content: { type: string; text?: string }[] | undefined): string { +function text(content: Block[] | undefined): string { return (content ?? []).map((block) => (block.type === 'text' ? (block.text ?? '') : `[${block.type} block]`)).join('\n') } @@ -182,9 +233,11 @@ export default definePlugin({ async apply(ctx, config) { const yoagent = ctx.use('yoagent') const cwd = config.cwd ?? process.cwd() - const names = { ...TOOL_NAMES, ...config.toolNames } + const names: Record = { ...TOOL_NAMES, ...config.toolNames } + const builtinFor = new Map(Object.entries(names).map(([yo, pi]) => [pi, yo])) const { SessionManager } = await import('@earendil-works/pi-coding-agent') + const { validateToolArguments } = await import('@earendil-works/pi-ai') const loaded = await (await piLoader()).loadExtensions(config.extensions, cwd) if (loaded.errors.length > 0) { throw new Error(`pi extensions failed to load: ${loaded.errors.map((e) => `${e.path}: ${e.error}`).join('; ')}`) @@ -192,6 +245,10 @@ export default definePlugin({ for (const w of loaded.warnings ?? []) console.warn(`[pi] ${short(w.path)}: ${w.warning}`) const extensions = loaded.extensions + const report = (message: string) => { + if (config.strict) throw new Error(`pi extensions use what does not map — ${message}`) + console.warn(`[pi] ${message}`) + } const unmapped: string[] = [] for (const ext of extensions) { const what = [ @@ -202,17 +259,19 @@ export default definePlugin({ ...(ext.messageRenderers.size + (ext.toolRenderers?.length ?? 0) + (ext.entryRenderers?.size ?? 0) > 0 ? ['renderers'] : []), + ...(loaded.runtime.pendingProviderRegistrations ?? []) + .filter((p) => p.extensionPath === ext.path) + .map((p) => `model provider ${p.name}`), + ...(loaded.runtime.mcpServers?.list() ?? []) + .filter((s) => s.extensionPath === ext.path) + .map((s) => `MCP server ${s.name}`), ] if (what.length > 0) unmapped.push(`${short(ext.path)}: ${what.join(', ')}`) } - if (unmapped.length > 0) { - const message = `not available in yoagent, ignored: ${unmapped.join('; ')}` - if (config.strict) throw new Error(`pi extensions use what does not map — ${message}`) - console.warn(`[pi] ${message}`) - } + if (unmapped.length > 0) report(`not available in yoagent, ignored: ${unmapped.join('; ')}`) const sessionManager = SessionManager.inMemory(cwd) - const context = (signal?: AbortSignal) => ({ + const context = (signal?: AbortSignal, systemPrompt = '') => ({ ui: NO_UI, mode: 'print', hasUI: false, @@ -226,7 +285,7 @@ export default definePlugin({ isProjectTrusted: () => false, hasPendingMessages: () => false, getContextUsage: () => undefined, - getSystemPrompt: () => '', + getSystemPrompt: () => systemPrompt, abort: () => { throw new Error('ctx.abort() is not available in yoagent') }, @@ -259,44 +318,57 @@ export default definePlugin({ } } - /** The tools to offer: every registered tool the model is meant to see, the latest registration of a name winning. */ - const tools = () => { + /** Every registered tool, the first registration of a name winning (pi's `getAllRegisteredTools`). */ + const registered = () => { const byName = new Map() for (const ext of extensions) { for (const { definition } of ext.tools.values()) { - const exposure = definition.exposure ?? 'direct' - if (exposure === 'direct' || exposure === 'model-only') byName.set(definition.name, definition) - else byName.delete(definition.name) + if (!byName.has(definition.name)) byName.set(definition.name, definition) } } return byName } + /** The ones pi would activate, so the ones the model is offered. */ + const offered = () => + new Map( + [...registered()].filter(([, tool]) => { + const exposure = tool.exposure ?? 'direct' + return (exposure === 'direct' || exposure === 'model-only') && tool.defaultActive !== false + }), + ) + + /** Overrides of yoagent tools already warned about. */ + const shadowWarned = new Set() // Notes from `before_agent_start`, computed once per run. const notes = new Map>() - const startNote = async (run: string, prompt: string, signal: AbortSignal) => { - const before = handlers('before_agent_start') - if (before.length === 0) return undefined + const startNote = async (prompt: string, signal: AbortSignal) => { let systemPrompt = PROMPT_MARK - const options = new Proxy({} as Record, { - set: () => { - throw new Error('changing systemPromptOptions is not available in yoagent; add text to systemPrompt instead') - }, - }) - for (const { ext, fn } of before) { + for (const { ext, fn } of handlers('before_agent_start')) { + const options = new Proxy({} as Record, { + get: () => undefined, + set: () => { + throw new Error('changing systemPromptOptions is not available in yoagent; add text to systemPrompt instead') + }, + }) const event = { type: 'before_agent_start', prompt, systemPrompt, systemPromptOptions: options } - const result = (await fn(event, context(signal))) as { systemPrompt?: string; message?: unknown } | undefined - if (result?.message !== undefined) { - throw new Error(`${short(ext.path)}: before_agent_start returned a message, which yoagent cannot inject`) - } - if (result?.systemPrompt !== undefined) { - if (!result.systemPrompt.includes(PROMPT_MARK)) { - throw new Error( - `${short(ext.path)}: before_agent_start replaced the system prompt; yoagent only takes text added to it`, - ) + try { + const result = (await fn(event, context(signal, systemPrompt))) as + | { systemPrompt?: string; message?: unknown } + | undefined + if (result?.message !== undefined) { + throw new Error('returned a message, which yoagent cannot inject') + } + if (result?.systemPrompt !== undefined) { + if (!result.systemPrompt.includes(PROMPT_MARK)) { + throw new Error('replaced the system prompt; yoagent only takes text added to it') + } + systemPrompt = result.systemPrompt } - systemPrompt = result.systemPrompt + } catch (error) { + // As in pi: one handler's failure is reported, the others still count. + console.warn(`[pi] ${short(ext.path)} before_agent_start, skipped: ${error instanceof Error ? error.message : error}`) } } const added = systemPrompt.split(PROMPT_MARK).map((part) => part.trim()).filter(Boolean) @@ -310,7 +382,16 @@ export default definePlugin({ ctx.effect( yoagent.register(config.name ?? 'pi-extensions', { async tools(): Promise { - return [...tools().values()].map((tool) => ({ + const tools = offered() + for (const name of tools.keys()) { + if (name in names && !shadowWarned.has(name)) { + shadowWarned.add(name) + report( + `pi tool "${name}" is named like yoagent's own tool: if the host also installs that one, yoagent's wins and the pi tool never runs — leave it out`, + ) + } + } + return [...tools.values()].map((tool) => ({ name: tool.name, label: tool.label ?? null, description: [tool.description ?? '', ...(tool.promptGuidelines ?? []).map((g) => `- ${g}`)] @@ -321,10 +402,11 @@ export default definePlugin({ }, async call_tool(call: ToolCall & Cancellable): Promise { - const tool = tools().get(call.tool) + const tool = offered().get(call.tool) if (!tool) return { text: `pi tool ${call.tool} is no longer registered`, is_error: true } - const params = tool.prepareArguments ? tool.prepareArguments(call.args) : call.args try { + // Prepared in before_tool; validated again here, in case a later handler rewrote them. + const params = validateToolArguments(tool as never, { name: tool.name, arguments: call.args } as never) const out = await tool.execute(call.call_id, params, call.signal, undefined, toolContext(call.signal)) return { text: text(out.content), details: out.details ?? null, is_error: out.isError === true } } catch (error) { @@ -333,13 +415,31 @@ export default definePlugin({ }, async before_tool(call: ToolCall & Cancellable) { - const policies = handlers('tool_call') - if (policies.length === 0) return - const toolName = names[call.tool] ?? call.tool - const shape = ARGS[toolName] - const original = shape ? shape.toPi(call.args) : call.args - const input = structuredClone(original) - for (const { ext, fn } of policies) { + const piTools = offered() + const own = piTools.get(call.tool) + // A yoagent built-in whose pi counterpart an extension overrides: the model must use the override. + const counterpart = names[call.tool] + if (!own && counterpart && counterpart !== call.tool && piTools.has(counterpart)) { + return { deny: `a pi extension replaces this tool with "${counterpart}"; call "${counterpart}" instead` } + } + let input: Args + let original: Args = call.args + if (own) { + // As pi's agent loop: prepare, validate, then the policies judge the validated arguments. + try { + const prepared = own.prepareArguments ? (own.prepareArguments(call.args) as Args) : call.args + input = validateToolArguments(own as never, { name: own.name, arguments: prepared } as never) as Args + } catch (error) { + return { deny: error instanceof Error ? error.message : String(error) } + } + } else { + const shape = ARGS[call.tool] + original = shape ? shape.toPi(call.args) : call.args + input = structuredClone(original) + } + const toolName = own ? call.tool : (counterpart ?? call.tool) + const before = JSON.stringify(input) + for (const { ext, fn } of handlers('tool_call')) { const event = { type: 'tool_call', toolCallId: call.call_id, toolName, input } let result: { block?: boolean; reason?: string } | undefined try { @@ -350,8 +450,13 @@ export default definePlugin({ } if (result?.block) return { deny: result.reason ?? `blocked by pi extension ${short(ext.path)}` } } - if (JSON.stringify(input) === JSON.stringify(original)) return + if (own) { + // Prepared or coerced arguments count as a rewrite too. + return JSON.stringify(input) === JSON.stringify(call.args) ? undefined : { args: input } + } + if (JSON.stringify(input) === before) return try { + const shape = ARGS[call.tool] return { args: shape ? shape.fromPi(input) : input } } catch (error) { return { deny: String(error) } @@ -361,47 +466,59 @@ export default definePlugin({ async after_tool(call: ToolCall & Cancellable, output: ToolOutput) { const editors = handlers('tool_result') if (editors.length === 0) return - const toolName = names[call.tool] ?? call.tool - const shape = ARGS[toolName] + const own = offered().has(call.tool) + const shape = own ? undefined : ARGS[call.tool] const event = { type: 'tool_result', toolCallId: call.call_id, - toolName, + toolName: own ? call.tool : (names[call.tool] ?? call.tool), input: shape ? shape.toPi(call.args) : call.args, - content: [{ type: 'text', text: output.text }], + // yoagent's text and image blocks have pi's shape. + content: output.content as Block[], details: output.details, isError: output.is_error, } - let changed = false + const changed = { content: false, details: false, isError: false } for (const { ext, fn } of editors) { try { const result = (await fn(event, context(call.signal))) as - | { content?: { type: string; text?: string }[]; details?: unknown; isError?: boolean } + | { content?: Block[]; details?: unknown; isError?: boolean } | undefined - if (!result) continue - if (result.content !== undefined) event.content = result.content as typeof event.content - if (result.details !== undefined) event.details = result.details - if (result.isError !== undefined) event.isError = result.isError - changed = true + if (result?.content !== undefined) { + event.content = result.content + changed.content = true + } + if (result?.details !== undefined) { + event.details = result.details + changed.details = true + } + if (result?.isError !== undefined) { + event.isError = result.isError + changed.isError = true + } } catch (error) { // As in pi: a failing tool_result handler is reported, the chain goes on. console.warn(`[pi] ${short(ext.path)} tool_result: ${error}`) } } - if (!changed) return - return { text: text(event.content), details: event.details ?? null, is_error: event.isError } + if (!changed.content && !changed.details && !changed.isError) return + return { + // Only a replaced content is sent back, as text: yoagent's edit replaces every block. + ...(changed.content ? { text: text(event.content) } : {}), + ...(changed.details ? { details: event.details ?? null } : {}), + ...(changed.isError ? { is_error: event.isError } : {}), + } }, async before_model(turn) { + if (handlers('before_agent_start').length === 0) return let note = notes.get(turn.run_id) if (!note) { - note = startNote(turn.run_id, turn.latest_user_text ?? '', turn.signal) + note = startNote(turn.latest_user_text ?? '', turn.signal) notes.set(turn.run_id, note) - // A failed attempt is not cached: the next request asks again. - note.catch(() => notes.delete(turn.run_id)) } - const text = await note - return text ? { note: text } : undefined + const added = await note + return added ? { note: added } : undefined }, async finish(outcome) { @@ -409,6 +526,5 @@ export default definePlugin({ }, }), ) - }, }) diff --git a/integrations/yoagent-rutis/tests/pi_test.rs b/integrations/yoagent-rutis/tests/pi_test.rs index 82ad9f9..19b424e 100644 --- a/integrations/yoagent-rutis/tests/pi_test.rs +++ b/integrations/yoagent-rutis/tests/pi_test.rs @@ -24,7 +24,8 @@ use rutis_loader::{ }; use serde_json::{json, Value}; use yoagent::provider::mock::{MockResponse, MockToolCall}; -use yoagent::tools::{BashTool, EditFileTool, WriteFileTool}; +use yoagent::tools::{BashTool, EditFileTool, ReadFileTool, SearchTool, WriteFileTool}; +use yoagent::{AgentMessage, Content, Message}; use yoagent_rutis::RutisBridge; fn pi_dir() -> PathBuf { @@ -102,14 +103,24 @@ async fn host(runtime: &Path) -> Host { } impl Host { - async fn load(&self, rows: Value) { + /// Load the rows; the loader's failures, as text (empty when none). + async fn try_load(&self, rows: Value) -> String { let patches: Vec = serde_json::from_value(json!([{ "insert": rows }])).unwrap(); let report = self .loader .reconcile(vec![Layer::new("rows", patches)], None) .await .unwrap(); - assert!(report.failures.is_empty(), "{report:?}"); + if report.failures.is_empty() { + String::new() + } else { + format!("{:?}", report.failures) + } + } + + async fn load(&self, rows: Value) { + let failures = self.try_load(rows).await; + assert!(failures.is_empty(), "{failures}"); let registry = self.bridge.registry().clone(); tokio::time::timeout(Duration::from_secs(60), async { while !registry @@ -269,4 +280,148 @@ async fn pi_extensions_reach_a_yoagent_agent_through_the_adapter() { let ended = wait_file(&slow, |t| t != "started").await; assert_eq!(ended, "aborted: AbortError"); host.root.shutdown().await.unwrap(); + // session_shutdown ran when the adapter unloaded. + wait_file(&project.path().join("shutdown.txt"), |t| t == "bye").await; +} + +/// A 1×1 PNG. +const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, +]; + +#[tokio::test(flavor = "multi_thread")] +async fn pi_semantics_on_the_less_common_paths() { + let Some(runtime) = node_runtime() else { + return; + }; + let project = tempfile::tempdir().unwrap(); + let notes = project.path().join("notes.txt"); + std::fs::write(¬es, "hello world").unwrap(); + let image = project.path().join("dot.png"); + std::fs::write(&image, PNG).unwrap(); + std::fs::write(project.path().join("a.md"), "needle in markdown").unwrap(); + std::fs::write(project.path().join("b.txt"), "needle in text").unwrap(); + + let host = host(&runtime).await; + host.load(json!([{ + "id": "pi", + "name": pi_dir().join("pi-extensions-adapter.ts"), + "config": { + "extensions": [pi_dir().join("fixture-extension.ts"), pi_dir().join("fixture-extra.ts")], + "cwd": project.path(), + }, + }])) + .await; + + let (agent, seen) = agent(vec![ + calls(&[ + ("pi_echo", json!({"text": "first"})), + ("pi_echo", json!({"text": "boom"})), + ("pi_soft_error", json!({})), + ("pi_inactive", json!({})), + ( + "edit_file", + json!({"path": notes, "old_text": "world", "new_text": "pi"}), + ), + ( + "edit", + json!({"path": "x.txt", "edits": "[{\"oldText\":\"a\",\"newText\":\"b\"}]"}), + ), + ("read_file", json!({"path": image})), + ( + "search", + json!({"pattern": "needle", "path": project.path(), "include": "*.md"}), + ), + ]), + text("done"), + ]); + let mut agent = agent + .with_tools(vec![ + Box::new(ReadFileTool::new()), + Box::new(EditFileTool::new()), + Box::new(SearchTool::new()), + ]) + .with_extension(host.bridge.extension()); + let (_, results) = tokio::time::timeout(Duration::from_secs(60), run(&mut agent, "go")) + .await + .expect("the run finishes"); + let seen_now = seen.lock().unwrap().clone(); + let result = |i: usize| results[i].clone(); + + // The first registration of a name wins, as in pi. + assert_eq!(result(0).1, "pi echo: first", "{results:?}"); + // A throwing tool_call handler blocks the call, as in pi. + assert!( + result(1).2 && result(1).1.contains("policy crashed"), + "{results:?}" + ); + // A returned isError is an error result. + assert!( + result(2).2 && result(2).1.contains("soft failure"), + "{results:?}" + ); + // defaultActive: false is not offered, so the call fails as unknown. + assert!(!seen_now[0].tools.contains(&"pi_inactive".to_string())); + assert!(result(3).2 && !result(3).1.contains("inactive ran")); + // yoagent's edit_file is denied: an extension overrides pi's `edit`. + assert!( + result(4).2 && result(4).1.contains(r#"call "edit" instead"#), + "{results:?}" + ); + assert_eq!(std::fs::read_to_string(¬es).unwrap(), "hello world"); + // The pi `edit` tool: prepared (edits parsed from a string) before the + // policy (which upper-cases newText), and never translated as edit_file. + let (_, text, is_error) = result(5); + assert!( + !is_error && text.contains(r#""edits":[{"oldText":"a","newText":"B"}]"#), + "{results:?}" + ); + // A details-only tool_result edit keeps the image. + let image_kept = agent.messages().iter().any(|m| { + matches!(m, AgentMessage::Llm(Message::ToolResult { tool_name, content, .. }) + if tool_name == "read_file" && content.iter().any(|c| matches!(c, Content::Image { .. }))) + }); + assert!(image_kept, "{:?}", agent.messages()); + // search's include is grep's glob: the policy's rewrite to *.txt reached the tool. + let (_, text, _) = result(7); + assert!( + text.contains("b.txt") && !text.contains("a.md"), + "{results:?}" + ); + // The crashing, message-returning and prompt-replacing before_agent_start + // handlers are skipped; the good one still adds its text. + let note = &seen_now[0].last_user; + assert!( + note.contains("Fixture rules: answer in one line.") && !note.contains("a whole new prompt"), + "{note}" + ); + host.root.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn strict_refuses_extensions_that_use_what_does_not_map() { + let Some(runtime) = node_runtime() else { + return; + }; + let host = host(&runtime).await; + let failures = host + .try_load(json!([{ + "id": "pi", + "name": pi_dir().join("pi-extensions-adapter.ts"), + "config": { + "extensions": [pi_dir().join("fixture-extension.ts")], + "strict": true, + }, + }])) + .await; + assert!( + failures.contains("command /fixture"), + "the fixture's command fails a strict load: {failures}" + ); + assert!(host.bridge.registry().handlers().is_empty()); + host.root.shutdown().await.unwrap(); } From b36ef32c1793850413633969b56e03748272b460 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 11:06:43 +0200 Subject: [PATCH 03/15] =?UTF-8?q?fix(rutis):=20pi=20adapter=20re-review=20?= =?UTF-8?q?=E2=80=94=20prepare=20on=20a=20copy,=20strict=20at=20load,=20se?= =?UTF-8?q?arch=20case=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prepareArguments runs on a copy and the rewrite check compares with the raw call (pi's own edit prepare mutates in place: was silently lost) - the same-name check runs at load after session_start (strict refuses the load, not the first run); names/ARGS are Maps - search: yoagent's default case-insensitivity is ignoreCase: true - before_agent_start: a message is dropped, its handler's addition kept - call_tool no longer re-validates (execute gets what the policies left) - native providers and virtual models reported; shutdown effect registered before the strict checks - tests: prepare-only call, details landed, a good prompt handler after failing ones, strict same-name refusal Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CLAUDE.md | 2 +- integrations/yoagent-rutis/README.md | 16 +- .../yoagent-rutis/plugins/pi/fixture-extra.ts | 18 ++- .../plugins/pi/pi-extensions-adapter.ts | 143 +++++++++++------- integrations/yoagent-rutis/tests/pi_test.rs | 86 +++++++++-- 5 files changed, 184 insertions(+), 81 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 36e2699..531c654 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.0; ~185 MB installed): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files), `cwd`, `name` (default `pi-extensions`), `toolNames`, `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` and `/.pi`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop: tools = `registered()` (first registration of a name wins, pi's `getAllRegisteredTools`) filtered to `offered()` (exposure `direct`/`model-only`, `defaultActive !== false`), read per run so `session_start` registrations count; `before_tool` for a pi tool runs `prepareArguments` then pi-ai's `validateToolArguments` (a throw → deny) **before** the `tool_call` policies, which see that object, and returns it as `{args}` when it differs from the raw call (`call_tool` validates again, no second prepare); for a yoagent built-in (`TOOL_NAMES` yoagent→pi: `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit`, `search`→`grep`, `list_files`→`find`) `ARGS` — keyed by the **yoagent** name, so a pi tool named `edit` is never translated — maps `edit_file` (`old_text`/`new_text` ↔ one `edits` entry; more → deny), `search` (`include`↔`glob`, `case_sensitive`↔`!ignoreCase`), `list_files` (`pattern` default `*`); a built-in whose pi name an offered pi tool has (`read` overrides `read_file`) is denied ("call X instead"); a pi tool named like a yoagent tool (`bash`) is warned once (fails under `strict`) — yoagent's static tool wins the merge. `{block}`/a throw → deny, in-place `event.input` change → `{args}`. `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, and returns only the fields set (`text` only when content was replaced). `before_agent_start` → `before_model` once per run (memoized by `run_id`, never rejected, cleared in `finish`; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder, the text around it becomes the note; per handler try/catch: a throw, a result without the placeholder, a `message`, or a `systemPromptOptions` write (throwing Proxy) is warned and skipped). `session_start` fired before `register`, `session_shutdown` on unload. Unmapped events/commands/shortcuts/flags/renderers/providers (`runtime.pendingProviderRegistrations`)/MCP servers (`runtime.mcpServers`) → one `console.warn` (or a load error with `strict`). Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `theme` = identity Proxy), `hasUI` false, `mode` `print`, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact`/`executeTool` throw. Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites, `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 with `prepareArguments`, a throwing policy, grep glob rewrite, details-only result edit, three bad `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, three tests incl. `strict`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks; chdirs into the temp project; `--without NAME` drops a yoagent built-in; scripted write to `.env` + `echo sudo rm -rf build`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/`, 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.0; ~185 MB installed): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files), `cwd`, `name` (default `pi-extensions`), `toolNames`, `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` and `/.pi`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop: tools = `registered()` (first registration of a name wins, pi's `getAllRegisteredTools`) filtered to `offered()` (exposure `direct`/`model-only`, `defaultActive !== false`), read per run so `session_start` registrations count; `before_tool` for a pi tool runs `prepareArguments` **on a `structuredClone`** (pi's own edit prepare mutates in place) then pi-ai's `validateToolArguments` (a throw → deny) **before** the `tool_call` policies, which see that object, and returns it as `{args}` when its JSON differs from the raw call's taken before preparing (`call_tool` neither prepares nor validates again: `execute` gets what the policies left, as in pi); for a yoagent built-in (`TOOL_NAMES` yoagent→pi: `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit`, `search`→`grep`, `list_files`→`find`) `ARGS` — keyed by the **yoagent** name, so a pi tool named `edit` is never translated — maps `edit_file` (`old_text`/`new_text` ↔ one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)` — yoagent's default is case-insensitive), `list_files` (`pattern` default `*`); a built-in whose pi name an offered pi tool has (`read` overrides `read_file`) is denied ("call X instead"); a pi tool named like a yoagent tool (`bash`) is checked at load after `session_start` (`checkShadowing`; fails under `strict`), later registrations only warned from `tools()` — yoagent's static tool wins the merge, while `before_tool` still treats the call as the pi tool's. `names`/`ARGS` are `Map`s (no prototype keys). `{block}`/a throw → deny, in-place `event.input` change → `{args}`. `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, and returns only the fields set (`text` only when content was replaced). `before_agent_start` → `before_model` once per run (memoized by `run_id`, never rejected, cleared in `finish`; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder, the text around it becomes the note; per handler try/catch: a throw, a result without the placeholder, or a `systemPromptOptions` write (throwing Proxy) is warned and skipped, never refused even under `strict`; a `message` is warned and dropped, the same result's `systemPrompt` kept). `session_start` fired before `register`, `session_shutdown` on unload. Unmapped events/commands/shortcuts/flags/renderers/providers (`runtime.pendingProviderRegistrations`, `pendingNativeProviderRegistrations`)/virtual models (`pendingVirtualModelRegistrations`)/MCP servers (`runtime.mcpServers`) → one `console.warn` (or a load error with `strict`), checked after `session_start`; the `session_shutdown` effect is registered before the checks, so a strict refusal still shuts the extensions down. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `theme` = identity Proxy), `hasUI` false, `mode` `print`, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact`/`executeTool` throw. Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites, `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 with `prepareArguments`, a throwing policy, grep glob rewrite, details-only result edit, a prepare-in-place `edit` override, three bad `before_agent_start` handlers and a good one after them). `tests/pi_test.rs` (`required-features = ["node"]`, four tests incl. two `strict` ones (unmapped API, a pi `bash`); skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks; chdirs into the temp project; `--without NAME` drops a yoagent built-in; scripted write to `.env` + `echo sudo rm -rf build`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/`, 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`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 781b54d..7de9cb4 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -322,25 +322,27 @@ onto one handler, following pi 1.1.0's own runner and agent loop: | pi | yoagent | |---|---| -| `pi.registerTool` | a tool, while pi would activate it (exposure `direct` / `model-only`, `defaultActive` not false; the first registration of a name wins). Arguments go through `prepareArguments` and pi's validation before any policy sees them; `execute` gets the bridge's cancel handle as its signal; a throw or `isError` is an error result. Tools registered at `session_start` are offered too | -| `on("tool_call")` | `before_tool` for every call, yoagent's built-ins under pi's names (`bash`, `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit` with `edits`, `search`→`grep` with `glob`/`ignoreCase`, `list_files`→`find`; config `toolNames` overrides): `{ block }` denies, in-place changes to `event.input` rewrite the arguments, a throwing handler blocks | +| `pi.registerTool` | a tool, while pi would activate it (exposure `direct` / `model-only`, `defaultActive` not false; the first registration of a name wins). Arguments go through `prepareArguments` and pi's validation before any policy sees them, and `execute` gets what the policies left (as in pi) with the bridge's cancel handle as its signal; a throw or `isError` is an error result. Tools registered at `session_start` are offered too | +| `on("tool_call")` | `before_tool` for every call, yoagent's built-ins under pi's names (`bash`, `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit` with `edits`, `search`→`grep` with `glob`/`ignoreCase` (yoagent's search is case-insensitive unless asked, pi's grep the reverse; translated), `list_files`→`find`; config `toolNames` overrides): `{ block }` denies, in-place changes to `event.input` rewrite the arguments, a throwing handler blocks | | `on("tool_result")` | `after_tool`: content, details and isError edits, chained; only the fields a handler set are applied (a replaced content keeps its text); a throwing handler is reported and skipped | -| `on("before_agent_start")` | `before_model`, once per run: text added around `event.systemPrompt` becomes a note on the latest user turn. A handler that throws, replaces the prompt, returns `message` or changes `systemPromptOptions` is reported and skipped; the others still count | +| `on("before_agent_start")` | `before_model`, once per run: text added around `event.systemPrompt` becomes a note on the latest user turn. A handler that throws, replaces the prompt or changes `systemPromptOptions` is reported and skipped, the others still count (a warning even under `strict`: a run cannot be refused at load, so a policy that replaces the prompt — a "read-only mode" — silently does not apply); a returned `message` is skipped, the same handler's addition kept | | `on("session_start")` / `on("session_shutdown")` | when the adapter loads / unloads | **Overrides.** An extension tool that replaces one of pi's built-ins under another name than yoagent's (`read`, `write`, `edit`, `grep`, `find`) makes the adapter deny yoagent's counterpart (`read_file`, ...), so the model cannot go around it. One named exactly like a yoagent tool (pi's sandboxed -`bash`) loses to yoagent's when the host installs both: the adapter warns -(fails under `strict`), and the host must leave its own tool out -(`--without bash` in the example). +`bash`) loses to yoagent's when the host installs both — and the adapter +would still prepare and validate yoagent's calls with the pi tool's schema: +it warns at load (fails under `strict`), and the host must leave its own +tool out (`--without bash` in the example). The rest does not map and is reported when an extension registers it (a warning; config `strict: true` makes it a load failure): events that rewrite or continue the conversation (`context`, `message_end`, `turn_end`, ...) or steer pi's session tree, commands, shortcuts, flags and renderers (a host -app's), and model providers and MCP servers. Runtime actions +app's), and model providers, virtual models and MCP servers (checked after +`session_start`). Runtime actions (`pi.sendMessage`, ...) throw pi's own "not initialized" error. There is no UI: `ctx.hasUI` is false and `ctx.ui` behaves as in pi's print mode (`confirm` answers false), so a policy that would ask the user denies. diff --git a/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts index 9dda283..0548037 100644 --- a/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts +++ b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts @@ -48,9 +48,11 @@ export default function (pi: ExtensionAPI) { path: Type.String(), edits: Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() })), }), + // In place, as pi's own edit tool does. prepareArguments(args: unknown) { const a = args as { path: string; edits: unknown } - return typeof a.edits === 'string' ? { ...a, edits: JSON.parse(a.edits) } : a + if (typeof a.edits === 'string') a.edits = JSON.parse(a.edits) + return a }, async execute(_id, params) { return { content: [{ type: 'text', text: `edit override: ${JSON.stringify(params)}` }], details: undefined } @@ -59,8 +61,11 @@ export default function (pi: ExtensionAPI) { pi.on('tool_call', async (event) => { if (event.toolName === 'pi_echo' && event.input.text === 'boom') throw new Error('policy crashed') - // yoagent's search, as pi's grep: its include is pi's glob. - if (event.toolName === 'grep' && event.input.glob === '*.md') event.input.glob = '*.txt' + // yoagent's search, as pi's grep: include is pi's glob, and an unset + // case_sensitive is pi's ignoreCase: true (yoagent searches case-insensitively). + if (event.toolName === 'grep' && event.input.glob === '*.md' && event.input.ignoreCase === true) { + event.input.glob = '*.txt' + } return undefined }) @@ -71,8 +76,11 @@ export default function (pi: ExtensionAPI) { pi.on('before_agent_start', async () => { throw new Error('prompt hook crashed') }) - pi.on('before_agent_start', async () => ({ + pi.on('before_agent_start', async (event) => ({ message: { customType: 'x', content: 'injected', display: false }, + systemPrompt: `${event.systemPrompt}\n\nMessage-handler rules: kept.`, })) - pi.on('before_agent_start', async (event) => ({ systemPrompt: 'a whole new prompt' })) + pi.on('before_agent_start', async () => ({ systemPrompt: 'a whole new prompt' })) + // After the failing ones: still counts. + pi.on('before_agent_start', async (event) => ({ systemPrompt: `${event.systemPrompt}\n\nExtra rules: last.` })) } diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts index 6c28557..dbac00d 100644 --- a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -20,14 +20,16 @@ // `prepareArguments` and pi's validation before any // policy sees them; `execute` gets the bridge's // cancel handle as its signal; a throw or `isError` -// is an error result. Tools registered later (in -// `session_start`, say) are offered from the next -// run on. A tool that overrides one of pi's -// built-ins (`read`, `edit`, ...) makes the adapter -// deny yoagent's counterpart (`read_file`, ...), so -// the model cannot go around it; one named exactly -// like a yoagent tool (`bash`) loses to that tool -// when the host installs it — the host must not. +// is an error result; `execute` gets the arguments +// the policies left, as in pi. Tools registered in +// `session_start` are offered from the first run. +// A tool that overrides one of pi's built-ins +// (`read`, `edit`, ...) makes the adapter deny +// yoagent's counterpart (`read_file`, ...), so the +// model cannot go around it; one named exactly like +// a yoagent tool (`bash`) loses to that tool when +// the host installs it — the host must not (a +// warning at load; a load failure with `strict`). // on("tool_call") `before_tool`, for every call of the run (the // agent's own built-ins too, under pi's names: see // TOOL_NAMES, arguments translated both ways): @@ -43,9 +45,11 @@ // on the request's latest user turn (yoagent never // rewrites the system prompt — the prompt cache // depends on it). A handler that throws, replaces -// the prompt, returns `message` or changes -// `systemPromptOptions` is reported and skipped; -// the others still count. +// the prompt or changes `systemPromptOptions` is +// reported and skipped (a warning even under +// `strict`: a run cannot be refused at load); the +// others still count. A returned `message` is +// skipped, the same handler's addition kept. // on("session_start") fired once, when the adapter starts; // on("session_shutdown") when it unloads. // @@ -54,7 +58,7 @@ // `context` and `message_end` rewrite the conversation, the boundary events // continue it, the session events steer pi's session tree — commands, // shortcuts, flags and renderers, which belong to the host app, and model -// providers and MCP servers. Runtime actions (`pi.sendMessage`, +// providers, virtual models and MCP servers (checked after `session_start`). Runtime actions (`pi.sendMessage`, // `pi.setActiveTools`, ...) throw pi's own "not initialized" error. // `ctx.hasUI` is false and `ctx.ui` behaves as in pi's print mode: `confirm` // answers false, `select` and `input` nothing, so a policy that would ask @@ -97,7 +101,7 @@ type Args = Record * built-in: only those calls are translated (a pi tool that is itself named * `edit` keeps its arguments as they are). */ -const ARGS: Record = { +const ARGS = new Map(Object.entries({ edit_file: { toPi: ({ old_text, new_text, ...rest }) => ({ ...rest, edits: [{ oldText: old_text, newText: new_text }] }), fromPi: ({ edits, ...rest }) => { @@ -112,12 +116,13 @@ const ARGS: Record ({ ...rest, ...(include === undefined ? {} : { glob: include }), - ...(case_sensitive === undefined ? {} : { ignoreCase: !case_sensitive }), + // yoagent's search is case-insensitive unless asked; pi's grep is case-sensitive unless asked. + ignoreCase: !(case_sensitive ?? false), }), fromPi: ({ glob, ignoreCase, ...rest }) => ({ ...rest, ...(glob === undefined ? {} : { include: glob }), - ...(ignoreCase === undefined ? {} : { case_sensitive: !ignoreCase }), + case_sensitive: ignoreCase === false, }), }, list_files: { @@ -125,7 +130,7 @@ const ARGS: Record ({ ...rest, pattern: pattern ?? '*' }), fromPi: ({ pattern, ...rest }) => (pattern === '*' ? rest : { ...rest, pattern }), }, -} +} as Record)) /** What `before_agent_start` handlers see as `event.systemPrompt`. */ const PROMPT_MARK = '\u0000yoagent-system-prompt\u0000' @@ -195,6 +200,8 @@ interface PiExtension { interface PiRuntime { pendingProviderRegistrations?: { name: string; extensionPath: string }[] + pendingNativeProviderRegistrations?: { provider: { id: string }; extensionPath: string }[] + pendingVirtualModelRegistrations?: { definition: { provider: string; id: string }; extensionPath: string }[] mcpServers?: { list(): { name: string; extensionPath?: string }[] } } @@ -233,8 +240,7 @@ export default definePlugin({ async apply(ctx, config) { const yoagent = ctx.use('yoagent') const cwd = config.cwd ?? process.cwd() - const names: Record = { ...TOOL_NAMES, ...config.toolNames } - const builtinFor = new Map(Object.entries(names).map(([yo, pi]) => [pi, yo])) + const names = new Map(Object.entries({ ...TOOL_NAMES, ...config.toolNames })) const { SessionManager } = await import('@earendil-works/pi-coding-agent') const { validateToolArguments } = await import('@earendil-works/pi-ai') @@ -249,26 +255,6 @@ export default definePlugin({ if (config.strict) throw new Error(`pi extensions use what does not map — ${message}`) console.warn(`[pi] ${message}`) } - const unmapped: string[] = [] - for (const ext of extensions) { - const what = [ - ...[...ext.handlers.keys()].filter((e) => !MAPPED_EVENTS.has(e)).map((e) => `event "${e}"`), - ...[...ext.commands.keys()].map((c) => `command /${c}`), - ...[...ext.shortcuts.keys()].map((s) => `shortcut ${s}`), - ...[...ext.flags.keys()].map((f) => `flag --${f}`), - ...(ext.messageRenderers.size + (ext.toolRenderers?.length ?? 0) + (ext.entryRenderers?.size ?? 0) > 0 - ? ['renderers'] - : []), - ...(loaded.runtime.pendingProviderRegistrations ?? []) - .filter((p) => p.extensionPath === ext.path) - .map((p) => `model provider ${p.name}`), - ...(loaded.runtime.mcpServers?.list() ?? []) - .filter((s) => s.extensionPath === ext.path) - .map((s) => `MCP server ${s.name}`), - ] - if (what.length > 0) unmapped.push(`${short(ext.path)}: ${what.join(', ')}`) - } - if (unmapped.length > 0) report(`not available in yoagent, ignored: ${unmapped.join('; ')}`) const sessionManager = SessionManager.inMemory(cwd) const context = (signal?: AbortSignal, systemPrompt = '') => ({ @@ -337,8 +323,49 @@ export default definePlugin({ }), ) - /** Overrides of yoagent tools already warned about. */ + const checkUnmapped = () => { + const unmapped: string[] = [] + for (const ext of extensions) { + const what = [ + ...[...ext.handlers.keys()].filter((e) => !MAPPED_EVENTS.has(e)).map((e) => `event "${e}"`), + ...[...ext.commands.keys()].map((c) => `command /${c}`), + ...[...ext.shortcuts.keys()].map((s) => `shortcut ${s}`), + ...[...ext.flags.keys()].map((f) => `flag --${f}`), + ...(ext.messageRenderers.size + (ext.toolRenderers?.length ?? 0) + (ext.entryRenderers?.size ?? 0) > 0 + ? ['renderers'] + : []), + ...(loaded.runtime.pendingProviderRegistrations ?? []) + .filter((p) => p.extensionPath === ext.path) + .map((p) => `model provider ${p.name}`), + ...(loaded.runtime.pendingNativeProviderRegistrations ?? []) + .filter((p) => p.extensionPath === ext.path) + .map((p) => `model provider ${p.provider.id}`), + ...(loaded.runtime.pendingVirtualModelRegistrations ?? []) + .filter((v) => v.extensionPath === ext.path) + .map((v) => `virtual model ${v.definition.provider}/${v.definition.id}`), + ...(loaded.runtime.mcpServers?.list() ?? []) + .filter((s) => s.extensionPath === ext.path) + .map((s) => `MCP server ${s.name}`), + ] + if (what.length > 0) unmapped.push(`${short(ext.path)}: ${what.join(', ')}`) + } + if (unmapped.length > 0) report(`not available in yoagent, ignored: ${unmapped.join('; ')}`) + } + + // A pi tool named exactly like a yoagent tool: checked at load, so `strict` refuses the load. const shadowWarned = new Set() + const checkShadowing = (strict: boolean) => { + for (const name of offered().keys()) { + if (!names.has(name) || shadowWarned.has(name)) continue + shadowWarned.add(name) + const message = + `pi tool "${name}" is named like yoagent's own tool. If the host also installs that one, yoagent's runs ` + + `and the pi tool never does, while the adapter still prepares and validates those calls with the pi tool's ` + + `schema — leave yoagent's out` + if (strict) report(message) + else console.warn(`[pi] ${message}`) + } + } // Notes from `before_agent_start`, computed once per run. const notes = new Map>() @@ -358,7 +385,7 @@ export default definePlugin({ | { systemPrompt?: string; message?: unknown } | undefined if (result?.message !== undefined) { - throw new Error('returned a message, which yoagent cannot inject') + console.warn(`[pi] ${short(ext.path)} before_agent_start: its message skipped (yoagent cannot inject one)`) } if (result?.systemPrompt !== undefined) { if (!result.systemPrompt.includes(PROMPT_MARK)) { @@ -377,20 +404,18 @@ export default definePlugin({ // Before registering: tools an extension adds at session start are offered from the first run. await fire('session_start', { type: 'session_start', reason: 'startup' }) + // Registered first, so a `strict` refusal below still shuts the extensions down. ctx.effect(() => fire('session_shutdown', { type: 'session_shutdown', reason: 'quit' })) + // After session_start, so what it registered is checked too. + checkUnmapped() + checkShadowing(config.strict === true) ctx.effect( yoagent.register(config.name ?? 'pi-extensions', { async tools(): Promise { + // Tools registered after load: warned only (a run is not the place to refuse). + checkShadowing(false) const tools = offered() - for (const name of tools.keys()) { - if (name in names && !shadowWarned.has(name)) { - shadowWarned.add(name) - report( - `pi tool "${name}" is named like yoagent's own tool: if the host also installs that one, yoagent's wins and the pi tool never runs — leave it out`, - ) - } - } return [...tools.values()].map((tool) => ({ name: tool.name, label: tool.label ?? null, @@ -405,9 +430,8 @@ export default definePlugin({ const tool = offered().get(call.tool) if (!tool) return { text: `pi tool ${call.tool} is no longer registered`, is_error: true } try { - // Prepared in before_tool; validated again here, in case a later handler rewrote them. - const params = validateToolArguments(tool as never, { name: tool.name, arguments: call.args } as never) - const out = await tool.execute(call.call_id, params, call.signal, undefined, toolContext(call.signal)) + // Prepared and validated in before_tool; as in pi, execute gets what the policies left. + const out = await tool.execute(call.call_id, call.args, call.signal, undefined, toolContext(call.signal)) return { text: text(out.content), details: out.details ?? null, is_error: out.isError === true } } catch (error) { return { text: error instanceof Error ? error.message : String(error), is_error: true } @@ -418,22 +442,25 @@ export default definePlugin({ const piTools = offered() const own = piTools.get(call.tool) // A yoagent built-in whose pi counterpart an extension overrides: the model must use the override. - const counterpart = names[call.tool] + const counterpart = names.get(call.tool) if (!own && counterpart && counterpart !== call.tool && piTools.has(counterpart)) { return { deny: `a pi extension replaces this tool with "${counterpart}"; call "${counterpart}" instead` } } let input: Args let original: Args = call.args + const raw = JSON.stringify(call.args) if (own) { // As pi's agent loop: prepare, validate, then the policies judge the validated arguments. try { - const prepared = own.prepareArguments ? (own.prepareArguments(call.args) as Args) : call.args + // On a copy: pi's own prepareArguments (edit's) mutates its argument in place. + const copy = structuredClone(call.args) + const prepared = own.prepareArguments ? (own.prepareArguments(copy) as Args) : copy input = validateToolArguments(own as never, { name: own.name, arguments: prepared } as never) as Args } catch (error) { return { deny: error instanceof Error ? error.message : String(error) } } } else { - const shape = ARGS[call.tool] + const shape = ARGS.get(call.tool) original = shape ? shape.toPi(call.args) : call.args input = structuredClone(original) } @@ -452,11 +479,11 @@ export default definePlugin({ } if (own) { // Prepared or coerced arguments count as a rewrite too. - return JSON.stringify(input) === JSON.stringify(call.args) ? undefined : { args: input } + return JSON.stringify(input) === raw ? undefined : { args: input } } if (JSON.stringify(input) === before) return try { - const shape = ARGS[call.tool] + const shape = ARGS.get(call.tool) return { args: shape ? shape.fromPi(input) : input } } catch (error) { return { deny: String(error) } @@ -467,11 +494,11 @@ export default definePlugin({ const editors = handlers('tool_result') if (editors.length === 0) return const own = offered().has(call.tool) - const shape = own ? undefined : ARGS[call.tool] + const shape = own ? undefined : ARGS.get(call.tool) const event = { type: 'tool_result', toolCallId: call.call_id, - toolName: own ? call.tool : (names[call.tool] ?? call.tool), + toolName: own ? call.tool : (names.get(call.tool) ?? call.tool), input: shape ? shape.toPi(call.args) : call.args, // yoagent's text and image blocks have pi's shape. content: output.content as Block[], diff --git a/integrations/yoagent-rutis/tests/pi_test.rs b/integrations/yoagent-rutis/tests/pi_test.rs index 19b424e..72b401f 100644 --- a/integrations/yoagent-rutis/tests/pi_test.rs +++ b/integrations/yoagent-rutis/tests/pi_test.rs @@ -25,7 +25,7 @@ use rutis_loader::{ use serde_json::{json, Value}; use yoagent::provider::mock::{MockResponse, MockToolCall}; use yoagent::tools::{BashTool, EditFileTool, ReadFileTool, SearchTool, WriteFileTool}; -use yoagent::{AgentMessage, Content, Message}; +use yoagent::{AgentEvent, AgentMessage, Content, Message}; use yoagent_rutis::RutisBridge; fn pi_dir() -> PathBuf { @@ -331,6 +331,11 @@ async fn pi_semantics_on_the_less_common_paths() { "edit", json!({"path": "x.txt", "edits": "[{\"oldText\":\"a\",\"newText\":\"b\"}]"}), ), + // Already upper-case: the policy changes nothing, only the preparation does. + ( + "edit", + json!({"path": "y.txt", "edits": "[{\"oldText\":\"c\",\"newText\":\"D\"}]"}), + ), ("read_file", json!({"path": image})), ( "search", @@ -346,7 +351,7 @@ async fn pi_semantics_on_the_less_common_paths() { Box::new(SearchTool::new()), ]) .with_extension(host.bridge.extension()); - let (_, results) = tokio::time::timeout(Duration::from_secs(60), run(&mut agent, "go")) + let (events, results) = tokio::time::timeout(Duration::from_secs(60), run(&mut agent, "go")) .await .expect("the run finishes"); let seen_now = seen.lock().unwrap().clone(); @@ -373,32 +378,57 @@ async fn pi_semantics_on_the_less_common_paths() { "{results:?}" ); assert_eq!(std::fs::read_to_string(¬es).unwrap(), "hello world"); - // The pi `edit` tool: prepared (edits parsed from a string) before the - // policy (which upper-cases newText), and never translated as edit_file. + // The pi `edit` tool: prepared in place (edits parsed from a string, as + // pi's own edit does) before the policy (which upper-cases newText), and + // never translated as edit_file. let (_, text, is_error) = result(5); assert!( !is_error && text.contains(r#""edits":[{"oldText":"a","newText":"B"}]"#), "{results:?}" ); + // Prepared arguments reach the tool even when no policy changes them. + let (_, text, is_error) = result(6); + assert!( + !is_error && text.contains(r#""edits":[{"oldText":"c","newText":"D"}]"#), + "{results:?}" + ); // A details-only tool_result edit keeps the image. let image_kept = agent.messages().iter().any(|m| { matches!(m, AgentMessage::Llm(Message::ToolResult { tool_name, content, .. }) if tool_name == "read_file" && content.iter().any(|c| matches!(c, Content::Image { .. }))) }); assert!(image_kept, "{:?}", agent.messages()); - // search's include is grep's glob: the policy's rewrite to *.txt reached the tool. - let (_, text, _) = result(7); + let details = events.iter().find_map(|e| match e { + AgentEvent::ToolExecutionEnd { + tool_name, result, .. + } if tool_name == "read_file" => Some(result.details.clone()), + _ => None, + }); + assert_eq!( + details, + Some(json!({"seen": true})), + "the details edit landed" + ); + // search's include is grep's glob, and its default case-insensitivity is + // ignoreCase: true: the policy's rewrite to *.txt reached the tool. + let (_, text, _) = result(8); assert!( text.contains("b.txt") && !text.contains("a.md"), "{results:?}" ); // The crashing, message-returning and prompt-replacing before_agent_start // handlers are skipped; the good one still adds its text. + // A message is skipped but its handler's addition kept; a handler after + // the failing ones still counts. let note = &seen_now[0].last_user; - assert!( - note.contains("Fixture rules: answer in one line.") && !note.contains("a whole new prompt"), - "{note}" - ); + for kept in [ + "Fixture rules: answer in one line.", + "Message-handler rules: kept.", + "Extra rules: last.", + ] { + assert!(note.contains(kept), "{kept} missing: {note}"); + } + assert!(!note.contains("a whole new prompt"), "{note}"); host.root.shutdown().await.unwrap(); } @@ -425,3 +455,39 @@ async fn strict_refuses_extensions_that_use_what_does_not_map() { assert!(host.bridge.registry().handlers().is_empty()); host.root.shutdown().await.unwrap(); } + +#[tokio::test(flavor = "multi_thread")] +async fn strict_refuses_a_pi_tool_named_like_a_yoagent_tool() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let ext = dir.path().join("sandboxed-bash.ts"); + std::fs::write( + &ext, + r#"import { Type } from 'typebox' +export default function (pi) { + pi.registerTool({ + name: 'bash', label: 'bash (sandboxed)', description: 'Run a command in a sandbox.', + parameters: Type.Object({ command: Type.String() }), + async execute() { return { content: [{ type: 'text', text: 'sandboxed' }], details: undefined } }, + }) +} +"#, + ) + .unwrap(); + let host = host(&runtime).await; + let failures = host + .try_load(json!([{ + "id": "pi", + "name": pi_dir().join("pi-extensions-adapter.ts"), + "config": { "extensions": [ext], "strict": true }, + }])) + .await; + assert!( + failures.contains(r#"pi tool \"bash\" is named like yoagent's own tool"#) + || failures.contains(r#"pi tool "bash" is named like yoagent's own tool"#), + "a same-name tool fails a strict load: {failures}" + ); + host.root.shutdown().await.unwrap(); +} From 18414639dae98f9696a135c0950fcd1ed2490f63 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 17:21:28 +0200 Subject: [PATCH 04/15] =?UTF-8?q?fix(rutis):=20pi=20adapter=20fails=20clos?= =?UTF-8?q?ed=20=E2=80=94=20full=20review=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical (fail closed by default): - setActiveTools is an enforced allowlist (offered tools and calls, the built-ins under pi's names); other runtime actions throw - a throwing session_start handler, an unfired deciding event (context, message_end, before_provider_*; unless allowUnmapped) and a pi tool named like a yoagent built-in (unless withoutBuiltins) refuse the load; found after load they stop the adapter (deny/reject/stop) - a throwing tool_result handler withholds the result Important: - call_tool runs a pi tool only with the arguments its policies judged - terminate: true stops the run at its next model request - a relative path is resolved against cwd before the policies judge it - a rewrite to a field the yoagent tool lacks is denied - input → on_input (handled/transform/throw reject) - example self-check asserts the denial; shutdown errors propagate - plugins/pi/node_modules excluded from the crate Also: onTerminalInput unsubscribe, 'then' on UI proxies, non-string systemPrompt, key-sorted JSON compares, docs corrected and dated. Tests: 8 (load refusals, same-name + withoutBuiltins, setActiveTools, per-run notes + input, judged arguments, and the less common paths: validation deny, find, built-in redaction, withheld result, unknown field, relative path, terminate, case-insensitive search, multi-edit deny); 12 safeguards mutation-checked. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- integrations/yoagent-rutis/Cargo.toml | 1 + integrations/yoagent-rutis/README.md | 99 ++-- .../yoagent-rutis/examples/pi_extensions.rs | 51 +- .../plugins/pi/fixture-extension.ts | 5 +- .../yoagent-rutis/plugins/pi/fixture-extra.ts | 20 +- .../plugins/pi/pi-extensions-adapter.ts | 556 ++++++++++++------ integrations/yoagent-rutis/rel.txt | 1 + integrations/yoagent-rutis/tests/pi_test.rs | 387 ++++++++++-- 10 files changed, 865 insertions(+), 259 deletions(-) create mode 100644 integrations/yoagent-rutis/rel.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index beab026..7cdf778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ adheres to [Semantic Versioning](https://semver.org/). ### yoagent-rutis -- **pi extensions** (`plugins/pi/pi-extensions-adapter.ts`): the tools and tool policies of [pi](https://github.com/earendil-works/pi) coding-agent extensions, loaded unchanged with pi's own loader, as one yoagent handler following pi 1.1.0's semantics. `registerTool` → tools (pi's activation and first-registration-wins, `prepareArguments` and validation before policies, cancel handle as the signal), `tool_call` → `before_tool` (yoagent's built-ins under pi's names, arguments translated both ways; an extension's override of a built-in is enforced), `tool_result` → `after_tool`, `before_agent_start` additions → a turn note (a failing handler skipped alone). What does not map (conversation rewriting, session events, commands, UI, providers, MCP servers) is reported, or fails loading with `strict`. Example `pi_extensions` (any extension files; `--live` with DeepSeek; `--without`), test `pi_test`. +- **pi extensions** (`plugins/pi/pi-extensions-adapter.ts`): the tools and tool policies of [pi](https://github.com/earendil-works/pi) coding-agent extensions, loaded unchanged with pi's own loader, as one yoagent handler following pi 1.1.0's semantics. `registerTool` → tools (pi's activation and first-registration-wins, `prepareArguments` and validation before the policies, only the judged arguments run), `setActiveTools` → an enforced allowlist, `tool_call` → `before_tool` (yoagent's built-ins under pi's names, arguments translated both ways, relative paths resolved where the policies look; an override of a built-in is enforced; `terminate` stops the run), `tool_result` → `after_tool` (a failing handler withholds the result), `input` → `on_input`, `before_agent_start` additions → a turn note. Fails closed: a load error, a failing `session_start`, an unfired deciding event (`context`, `message_end`, ...; unless `allowUnmapped`) or a pi tool named like a yoagent built-in (unless `withoutBuiltins`) refuses the load; other unmapped API (observer and session events, commands, renderers, providers, MCP servers) is reported, or refuses with `strict`. Example `pi_extensions` (any extension files; `--live` with DeepSeek; `--without`), test `pi_test`. ## 0.25.0 (2026-10-08) diff --git a/CLAUDE.md b/CLAUDE.md index 531c654..de5692e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.0; ~185 MB installed): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files), `cwd`, `name` (default `pi-extensions`), `toolNames`, `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` and `/.pi`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop: tools = `registered()` (first registration of a name wins, pi's `getAllRegisteredTools`) filtered to `offered()` (exposure `direct`/`model-only`, `defaultActive !== false`), read per run so `session_start` registrations count; `before_tool` for a pi tool runs `prepareArguments` **on a `structuredClone`** (pi's own edit prepare mutates in place) then pi-ai's `validateToolArguments` (a throw → deny) **before** the `tool_call` policies, which see that object, and returns it as `{args}` when its JSON differs from the raw call's taken before preparing (`call_tool` neither prepares nor validates again: `execute` gets what the policies left, as in pi); for a yoagent built-in (`TOOL_NAMES` yoagent→pi: `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit`, `search`→`grep`, `list_files`→`find`) `ARGS` — keyed by the **yoagent** name, so a pi tool named `edit` is never translated — maps `edit_file` (`old_text`/`new_text` ↔ one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)` — yoagent's default is case-insensitive), `list_files` (`pattern` default `*`); a built-in whose pi name an offered pi tool has (`read` overrides `read_file`) is denied ("call X instead"); a pi tool named like a yoagent tool (`bash`) is checked at load after `session_start` (`checkShadowing`; fails under `strict`), later registrations only warned from `tools()` — yoagent's static tool wins the merge, while `before_tool` still treats the call as the pi tool's. `names`/`ARGS` are `Map`s (no prototype keys). `{block}`/a throw → deny, in-place `event.input` change → `{args}`. `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, and returns only the fields set (`text` only when content was replaced). `before_agent_start` → `before_model` once per run (memoized by `run_id`, never rejected, cleared in `finish`; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder, the text around it becomes the note; per handler try/catch: a throw, a result without the placeholder, or a `systemPromptOptions` write (throwing Proxy) is warned and skipped, never refused even under `strict`; a `message` is warned and dropped, the same result's `systemPrompt` kept). `session_start` fired before `register`, `session_shutdown` on unload. Unmapped events/commands/shortcuts/flags/renderers/providers (`runtime.pendingProviderRegistrations`, `pendingNativeProviderRegistrations`)/virtual models (`pendingVirtualModelRegistrations`)/MCP servers (`runtime.mcpServers`) → one `console.warn` (or a load error with `strict`), checked after `session_start`; the `session_shutdown` effect is registered before the checks, so a strict refusal still shuts the extensions down. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `theme` = identity Proxy), `hasUI` false, `mode` `print`, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact`/`executeTool` throw. Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites, `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 with `prepareArguments`, a throwing policy, grep glob rewrite, details-only result edit, a prepare-in-place `edit` override, three bad `before_agent_start` handlers and a good one after them). `tests/pi_test.rs` (`required-features = ["node"]`, four tests incl. two `strict` ones (unmapped API, a pi `bash`); skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks; chdirs into the temp project; `--without NAME` drops a yoagent built-in; scripted write to `.env` + `echo sudo rm -rf build`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/`, 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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()`) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. 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 (pi names), `getActiveTools`/`getAllTools` from it; `UNSUPPORTED_ACTIONS` (`sendMessage`, `appendEntry`, ...) 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 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. `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 (args cross serde_json). `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`/`executeTool` throw. 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"]`, 8 tests: 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/`, 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`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/integrations/yoagent-rutis/Cargo.toml b/integrations/yoagent-rutis/Cargo.toml index 730a92e..72f05fd 100644 --- a/integrations/yoagent-rutis/Cargo.toml +++ b/integrations/yoagent-rutis/Cargo.toml @@ -17,6 +17,7 @@ exclude = [ "plugins/node_modules", "plugins/.venv", "plugins/dsh/node_modules", + "plugins/pi/node_modules", "examples/rutis-agent-tools", ] # yoagent's MSRV; rutis 0.6.1 declares 1.85. diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 7de9cb4..4d1f6a2 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -322,37 +322,60 @@ onto one handler, following pi 1.1.0's own runner and agent loop: | pi | yoagent | |---|---| -| `pi.registerTool` | a tool, while pi would activate it (exposure `direct` / `model-only`, `defaultActive` not false; the first registration of a name wins). Arguments go through `prepareArguments` and pi's validation before any policy sees them, and `execute` gets what the policies left (as in pi) with the bridge's cancel handle as its signal; a throw or `isError` is an error result. Tools registered at `session_start` are offered too | -| `on("tool_call")` | `before_tool` for every call, yoagent's built-ins under pi's names (`bash`, `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit` with `edits`, `search`→`grep` with `glob`/`ignoreCase` (yoagent's search is case-insensitive unless asked, pi's grep the reverse; translated), `list_files`→`find`; config `toolNames` overrides): `{ block }` denies, in-place changes to `event.input` rewrite the arguments, a throwing handler blocks | -| `on("tool_result")` | `after_tool`: content, details and isError edits, chained; only the fields a handler set are applied (a replaced content keeps its text); a throwing handler is reported and skipped | -| `on("before_agent_start")` | `before_model`, once per run: text added around `event.systemPrompt` becomes a note on the latest user turn. A handler that throws, replaces the prompt or changes `systemPromptOptions` is reported and skipped, the others still count (a warning even under `strict`: a run cannot be refused at load, so a policy that replaces the prompt — a "read-only mode" — silently does not apply); a returned `message` is skipped, the same handler's addition kept | -| `on("session_start")` / `on("session_shutdown")` | when the adapter loads / unloads | +| `pi.registerTool` | a tool, while pi would activate it (exposure `direct` / `model-only`, `defaultActive` not false, in the `setActiveTools` allowlist if one was set; the first registration of a name wins). Arguments go through `prepareArguments` and pi's validation before any policy sees them, and `execute` runs only with the arguments the policies left — a call another handler rewrote afterwards is not run. A throw or `isError` is an error result (non-text blocks become `[image block]` text) | +| `pi.setActiveTools` | an allowlist: other tools are not offered, and calls to them — yoagent's built-ins under pi's names included — are denied; `getActiveTools` / `getAllTools` answer from the same view | +| `on("tool_call")` | `before_tool` for every call. yoagent's built-ins are judged under pi's names (`bash`, `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit` with `edits`, `search`→`grep` with `glob` / `ignoreCase` (yoagent's search is case-insensitive unless asked, pi's grep the reverse), `list_files`→`find`; config `toolNames` adds more), with a relative `path` resolved against `cwd` first so the tool acts where the policy looked. `{ block }` denies (`terminate: true` also stops the run at its next model request); in-place changes to `event.input` rewrite the arguments, and one the yoagent tool cannot take (a second edit, a `timeout` on `bash`) denies the call; a throwing handler blocks, as in pi | +| `on("tool_result")` | `after_tool`: content, details and isError edits, chained; only the fields a handler set are applied (a replaced content keeps only its text). A throwing handler **withholds the result** — unlike pi, which skips it: a failed redaction must not let the raw output through | +| `on("input")` | `on_input`: `handled` rejects the prompt (it never reaches the agent, as in pi); `transform` and a throwing handler reject it too (yoagent cannot rewrite a prompt) | +| `on("before_agent_start")` | `before_model`: the handlers run once per run, and the text they add around `event.systemPrompt` is a note on the latest user turn of every request of that run. A handler that throws, replaces the prompt or changes `systemPromptOptions` is skipped with a warning — even under `strict` (a run cannot refuse the load), so a policy that replaces the prompt (a "read-only mode") does not apply; the others still count. A returned `message` is dropped, the same handler's addition kept | +| `on("session_start")` / `on("session_shutdown")` | when the adapter loads / unloads; a `session_start` handler that throws refuses the load (its extension may be missing its policies) | + +**What refuses the load** (fail closed, `strict` or not): an extension that +fails to load (nothing loads, not just the bad one); a `session_start` +failure; a handler for an event yoagent never fires that would decide or +rewrite what the agent does (`context`, `context_with_system`, +`message_end`, `before_provider_request`, `before_provider_headers`) unless +the host lists it in `allowUnmapped`; and a pi tool named exactly like a +yoagent built-in (pi's sandboxed `bash`) unless the host left that built-in +out and says so in `withoutBuiltins` — otherwise yoagent's own tool would +win the merge and run unsandboxed. Anything of this kind registered after +load (inside a handler) stops the adapter instead: every later call denied, +prompt rejected, run stopped. **Overrides.** An extension tool that replaces one of pi's built-ins under another name than yoagent's (`read`, `write`, `edit`, `grep`, `find`) makes the adapter deny yoagent's counterpart (`read_file`, ...), so the model -cannot go around it. One named exactly like a yoagent tool (pi's sandboxed -`bash`) loses to yoagent's when the host installs both — and the adapter -would still prepare and validate yoagent's calls with the pi tool's schema: -it warns at load (fails under `strict`), and the host must leave its own -tool out (`--without bash` in the example). - -The rest does not map and is reported when an extension registers it (a -warning; config `strict: true` makes it a load failure): events that rewrite -or continue the conversation (`context`, `message_end`, `turn_end`, ...) or -steer pi's session tree, commands, shortcuts, flags and renderers (a host -app's), and model providers, virtual models and MCP servers (checked after -`session_start`). Runtime actions -(`pi.sendMessage`, ...) throw pi's own "not initialized" error. There is no -UI: `ctx.hasUI` is false and `ctx.ui` behaves as in pi's print mode -(`confirm` answers false), so a policy that would ask the user denies. - -Config: `extensions` (files, loaded in order), `cwd` (the project the -extensions see; default the runtime's), `name`, `toolNames`, `strict`. Load -it as a row of a Node runtime whose `package.json` is `plugins/pi/`'s -(pi 1.1.0, pinned exactly: the adapter imports pi's loader by file, since the -package exports only the variant that also loads `~/.pi`; check it on every -pi upgrade), with `yoagent` shared in the loader's catalog. +cannot go around it. + +**What is only reported** (a warning on the Node process's stderr; config +`strict: true` makes it a load failure): every other unfired event — +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. Other runtime actions (`pi.sendMessage`, `pi.appendEntry`, ...) +throw "not available in yoagent". There is no UI: `ctx.hasUI` is false and +`ctx.ui` behaves as in pi's print mode (`confirm` answers false), so a +policy that would ask the user denies. + +**Host setup.** Install the bridge's extension with `.require_policy()`, so +a run that starts before the adapter registered (or after it failed to +load) has every tool call denied rather than unjudged; and with +`.rechecks_modified_calls()` when other handlers can rewrite calls after +the adapter (pi tools are protected by the adapter itself; yoagent's +built-ins are not). Policies cover what the agent calls under the names pi +knows: a pi policy for `write` does not see a `bash` command that writes +the same file (as in pi), nor tools outside `TOOL_NAMES` / `toolNames` +(MCP tools, sub-agents, your own) under any but their own names. + +Config: `extensions` (files, or directories with an `index.ts` / +`index.js`, loaded in order; relative paths resolve against `cwd`), `cwd` +(the project the extensions see; default the runtime's), `name`, +`toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`. Load it as a +row of a Node runtime whose `package.json` is `plugins/pi/`'s (pi 1.1.0, +pinned exactly: the adapter imports pi's loader by file, since the package +exports only `discoverAndLoadExtensions`, which also loads +`/.pi/extensions` and `~/.pi/agent/extensions`; check it on every pi +upgrade), with `yoagent` shared in the loader's catalog. ```sh (cd plugins/pi && npm ci) @@ -364,16 +387,18 @@ cargo run --features node --example pi_extensions -- --live --prompt "..." [--wi The example runs yoagent's own tools in the temporary project the extensions see (it is the process's working directory). -Tried with eleven of pi's own examples, unchanged, scripted and live with -DeepSeek: `hello`, `todo`, `tool-override` (`read`; yoagent's `read_file` is -then denied), `truncated-tool` (`rg`) and `dynamic-tools` offer working -tools; `protected-paths` and `permission-gate` judge yoagent's own -`write_file` and `bash`; `claude-rules` loads (its note needs a project with -`.claude/rules/`, which these runs did not have). `pirate` only acts after its `/pirate` command, and -`dirty-repo-guard` and `confirm-destructive` only on pi's session events, so -under the adapter they load and do nothing (reported). `tests/pi_test.rs` -covers the adapter offline with two fixture extensions -(`plugins/pi/fixture-extension.ts`, `plugins/pi/fixture-extra.ts`). +Tried (pi 1.1.0, October 2026) with eleven of pi's own examples, unchanged, +scripted and live with DeepSeek: `hello`, `todo`, `tool-override` (`read`; +yoagent's `read_file` is then denied), `truncated-tool` (`rg`) and +`dynamic-tools` offer working tools; `protected-paths` and +`permission-gate` judge yoagent's own `write_file` and `bash`; +`claude-rules` loads (its note needs a project with `.claude/rules/`, which +these runs did not have). `pirate` only acts after its `/pirate` command, +and `dirty-repo-guard` and `confirm-destructive` only on pi's session +events, so under the adapter they load and do nothing (reported). +`tests/pi_test.rs` covers the adapter offline with the fixture extensions +(`plugins/pi/fixture-extension.ts`, `plugins/pi/fixture-extra.ts`) and small +ones written per test. ## Semantics diff --git a/integrations/yoagent-rutis/examples/pi_extensions.rs b/integrations/yoagent-rutis/examples/pi_extensions.rs index d43a950..f5d3e57 100644 --- a/integrations/yoagent-rutis/examples/pi_extensions.rs +++ b/integrations/yoagent-rutis/examples/pi_extensions.rs @@ -18,9 +18,10 @@ //! //! The project is also the process's working directory, so yoagent's own //! tools (`bash`, relative paths) act there, not where `cargo run` started. -//! `--without NAME` leaves a yoagent built-in out (repeatable): use it when -//! an extension replaces one under the same name, e.g. pi's sandboxed -//! `bash` (yoagent's own tool would otherwise win and run unsandboxed). +//! `--without NAME` leaves a yoagent built-in out (repeatable) and tells the +//! adapter so (`withoutBuiltins`): needed when an extension replaces one +//! under the same name, e.g. pi's sandboxed `bash` — the adapter refuses to +//! load otherwise, since yoagent's own tool would win and run unsandboxed. //! //! Setup (once): `npm ci` in `plugins/pi/` (Node 24+). //! @@ -160,7 +161,12 @@ async fn main() -> Result<(), BoxError> { let patches: Vec = serde_json::from_value(json!([{ "insert": [{ "id": "pi", "name": pi_dir().join("pi-extensions-adapter.ts"), - "config": { "extensions": extensions, "cwd": project.path() }, + "config": { + "extensions": extensions, + "cwd": project.path(), + // yoagent built-ins left out, so pi tools of the same name are the ones that run. + "withoutBuiltins": without, + }, }] }]))?; let report = loader .reconcile(vec![Layer::new("app", patches)], None) @@ -190,12 +196,12 @@ async fn main() -> Result<(), BoxError> { name: "write_file".into(), arguments: json!({"path": env_file, "content": "TOKEN=1"}), }, + // Harmless if a policy lets it through; pi's permission-gate + // flags `sudo`. MockToolCall { provider_metadata: None, name: "bash".into(), - arguments: // Harmless if a policy lets it through; pi's permission-gate - // flags `sudo`. - json!({"command": "echo sudo rm -rf build"}), + arguments: json!({"command": "echo sudo rm -rf build"}), }, ]), MockResponse::Text("(scripted) done.".into()), @@ -277,9 +283,25 @@ async fn main() -> Result<(), BoxError> { let written = env_file.exists(); println!(".env written: {written}"); let offered = seen.lock().unwrap().first().map(|(t, _)| t.clone()); - let _ = tokio::time::timeout(Duration::from_secs(10), root.shutdown()).await; + let results: Vec<(String, String, bool)> = agent + .messages() + .iter() + .filter_map(|m| match m { + AgentMessage::Llm(Message::ToolResult { + tool_name, + content, + is_error, + .. + }) => Some((tool_name.clone(), text_of(content), *is_error)), + _ => None, + }) + .collect(); + tokio::time::timeout(Duration::from_secs(10), root.shutdown()) + .await + .map_err(|_| "the plugins did not shut down within 10 s")??; - // Scripted with the fixture: its tools were offered and its policy held. + // Scripted with the fixture: its tools were offered, and its policy denied + // the write (not a failed run or an unavailable handler). if fixture && !live { if written { return Err("the fixture's policy should have blocked the write to .env".into()); @@ -287,7 +309,16 @@ async fn main() -> Result<(), BoxError> { if !offered.unwrap_or_default().iter().any(|t| t == "pi_echo") { return Err("the fixture's pi_echo tool was not offered".into()); } - println!("ok: the fixture's tools were offered and its policy held"); + let denied = results.iter().any(|(tool, text, is_error)| { + tool == "write_file" && *is_error && text.contains("is protected") + }); + if !denied { + return Err(format!("the fixture's policy did not deny the write: {results:?}").into()); + } + if !results.iter().any(|(tool, _, _)| tool == "bash") { + return Err(format!("the bash call never ran: {results:?}").into()); + } + println!("ok: the fixture's tools were offered and its policy denied the write"); } Ok(()) } diff --git a/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts b/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts index 43c6fd6..bb5d22f 100644 --- a/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts +++ b/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts @@ -1,6 +1,7 @@ // A pi extension for the adapter's tests (`tests/pi_test.rs`): written as // any pi extension is, against pi's own API, with no knowledge of yoagent. -// No network; the slow tool writes `slow.txt` in `ctx.cwd`. +// No network; the slow tool writes `slow.txt` and `session_shutdown` writes +// `shutdown.txt` in `ctx.cwd`. import { writeFileSync } from 'node:fs' import { join } from 'node:path' @@ -73,6 +74,8 @@ export default function (pi: ExtensionAPI) { if (event.toolName === 'edit') { const edits = event.input.edits as { oldText: string; newText: string }[] edits[0].newText = edits[0].newText.toUpperCase() + // Two edits: yoagent's edit_file runs one, so the call is denied. + if (String(event.input.path).endsWith('multi.txt')) edits.push({ oldText: 'x', newText: 'y' }) } return undefined }) diff --git a/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts index 0548037..dbfc325 100644 --- a/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts +++ b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts @@ -1,5 +1,6 @@ // A second pi extension for `tests/pi_test.rs`, loaded after -// `fixture-extension.ts`: the less common paths. +// `fixture-extension.ts`: the less common paths. Markers in the arguments +// (`echo bounded`, `echo BREAK`, `echo stop-now`) select a case. import { type ExtensionAPI } from '@earendil-works/pi-coding-agent' import { Type } from 'typebox' @@ -66,13 +67,28 @@ export default function (pi: ExtensionAPI) { if (event.toolName === 'grep' && event.input.glob === '*.md' && event.input.ignoreCase === true) { event.input.glob = '*.txt' } + // yoagent's list_files, as pi's find: the required pattern defaults to '*'. + if (event.toolName === 'find' && event.input.pattern === '*') event.input.path = `${event.input.path}/inner` + // A field yoagent's bash does not have: denied, not silently dropped. + if (event.toolName === 'bash' && event.input.command === 'echo bounded') event.input.timeout = 30 + if (event.toolName === 'bash' && event.input.command === 'echo stop-now') { + return { block: true, reason: 'stopping the run', terminate: true } + } return undefined }) // A details-only edit must keep the content (images included). pi.on('tool_result', async (event) => (event.toolName === 'read' ? { details: { seen: true } } : undefined)) + // A redaction that fails: the result is withheld. + pi.on('tool_result', async (event) => { + const text = event.content.map((block) => (block.type === 'text' ? block.text : '')).join('') + if (text.includes('BREAK')) throw new Error('redaction crashed') + return undefined + }) - // Each is skipped on its own; the first fixture's addition still counts. + // The crashing and the prompt-replacing handlers are skipped; the + // message-returning one loses only its message; the first fixture's + // addition and the last handler's still count. pi.on('before_agent_start', async () => { throw new Error('prompt hook crashed') }) diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts index dbac00d..fa3e438 100644 --- a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -8,75 +8,100 @@ // here), then maps the part of the API that belongs to an agent loop onto // one yoagent handler. Load it as a rutis-loader row of a Node runtime whose // package.json is this directory's, with `yoagent` shared in the loader's -// catalog, and list the extension files in its config: +// catalog, and list the extensions in its config (files, or directories with +// an `index.ts` / `index.js`; relative paths resolve against `cwd`): // // { "name": "", "config": { "extensions": ["./my-ext.ts"], "cwd": "/repo" } } // -// What maps, per pi API (following pi 1.1.0's own runner and agent loop): +// What maps (following pi 1.1.0's own runner and agent loop): // pi.registerTool a yoagent tool, offered while pi would activate it // (exposure `direct` / `model-only`, `defaultActive` -// not false; the first registration of a name wins, -// as in pi). Arguments go through the tool's -// `prepareArguments` and pi's validation before any -// policy sees them; `execute` gets the bridge's -// cancel handle as its signal; a throw or `isError` -// is an error result; `execute` gets the arguments -// the policies left, as in pi. Tools registered in -// `session_start` are offered from the first run. +// not false, active under `setActiveTools`; the +// first registration of a name wins). Arguments go +// through `prepareArguments` and pi's validation +// before any policy sees them; `execute` gets the +// arguments the policies left, and only those: a +// call another handler rewrote afterwards is not +// run. A throw or `isError` is an error result +// (non-text blocks become `[image block]` text). // A tool that overrides one of pi's built-ins // (`read`, `edit`, ...) makes the adapter deny // yoagent's counterpart (`read_file`, ...), so the -// model cannot go around it; one named exactly like -// a yoagent tool (`bash`) loses to that tool when -// the host installs it — the host must not (a -// warning at load; a load failure with `strict`). -// on("tool_call") `before_tool`, for every call of the run (the -// agent's own built-ins too, under pi's names: see -// TOOL_NAMES, arguments translated both ways): -// `{ block }` denies with its reason; changes to -// `event.input` rewrite the arguments. A handler -// that throws blocks, as in pi. +// model cannot go around it. A tool named exactly +// like a yoagent built-in (pi's sandboxed `bash`) +// refuses the load unless the host says it left +// that built-in out (`withoutBuiltins`). +// pi.setActiveTools an allowlist: tools outside it are not offered +// and their calls (yoagent's built-ins under pi's +// names) are denied. `getActiveTools` / +// `getAllTools` answer from the same view. +// on("tool_call") `before_tool`, for every call of the run. yoagent's +// built-ins are judged under pi's names (TOOL_NAMES), +// their arguments translated both ways and a +// relative `path` resolved against `cwd` first, so +// the tool acts where the policy looked. `{ block }` +// denies (`terminate: true` also stops the run at +// its next model request); in-place changes to +// `event.input` rewrite the arguments (a field the +// yoagent tool does not have denies the call); a +// handler that throws blocks, as in pi. // on("tool_result") `after_tool`: changes to content, details and -// isError are chained, then applied (a replaced -// content keeps only its text). A handler that -// throws is reported and skipped, as in pi. -// on("before_agent_start") `before_model`, once per run: text a handler -// adds around `event.systemPrompt` becomes a note -// on the request's latest user turn (yoagent never -// rewrites the system prompt — the prompt cache -// depends on it). A handler that throws, replaces -// the prompt or changes `systemPromptOptions` is -// reported and skipped (a warning even under -// `strict`: a run cannot be refused at load); the -// others still count. A returned `message` is -// skipped, the same handler's addition kept. -// on("session_start") fired once, when the adapter starts; +// isError are chained; only the fields a handler +// set are applied (a replaced content keeps only its +// text). A handler that throws withholds the result +// — unlike pi, which skips it: a redaction that +// failed must not let the raw output through. +// on("input") `on_input`: `handled` rejects the prompt (it never +// reaches the agent, as in pi); `transform` and a +// handler that throws reject it too (yoagent cannot +// rewrite a prompt; failing closed). +// on("before_agent_start") `before_model`: the handlers run once per run; +// text they add around `event.systemPrompt` is a +// note on the latest user turn of every request of +// the run (yoagent never rewrites the system prompt +// — the prompt cache depends on it). A handler that +// throws, replaces the prompt or changes +// `systemPromptOptions` is skipped with a warning +// (its policy does not apply); the others still +// count. A returned `message` is dropped, the same +// handler's addition kept. +// on("session_start") fired once, when the adapter starts; a handler +// that throws refuses the load (its extension did +// not finish setting up — its policies included). // on("session_shutdown") when it unloads. // -// What does not map, and is reported when an extension registers it (a -// warning, or a load failure with `strict: true`): every other event — -// `context` and `message_end` rewrite the conversation, the boundary events -// continue it, the session events steer pi's session tree — commands, -// shortcuts, flags and renderers, which belong to the host app, and model -// providers, virtual models and MCP servers (checked after `session_start`). Runtime actions (`pi.sendMessage`, -// `pi.setActiveTools`, ...) throw pi's own "not initialized" error. -// `ctx.hasUI` is false and `ctx.ui` behaves as in pi's print mode: `confirm` -// answers false, `select` and `input` nothing, so a policy that would ask -// the user denies instead. - +// What does not map: no other event is ever fired. Events that would decide +// or rewrite something yoagent does (DECIDING_EVENTS: `context`, +// `message_end`, `before_provider_request`, ...) 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 is reported then, +// and a deciding one stops the adapter: every later call denied. Other +// runtime actions (`pi.sendMessage`, `pi.appendEntry`, ...) throw "not +// available in yoagent". `ctx.hasUI` is false and `ctx.ui` behaves as in +// pi's print mode: `confirm` answers false, `select` and `input` nothing, so +// a policy that would ask the user denies instead. + +import { isAbsolute, resolve } from 'node:path' import { definePlugin } from '@arcships/rutis' import type { Cancellable, ToolCall, ToolOutput, ToolResult, ToolSpec, Yoagent } from '../yoagent.d.ts' export interface Config { /** The handler's name in the bridge (unique across the host's plugins). */ name?: string - /** pi extension files (or directories with an `index.ts`), loaded in order. */ + /** pi extensions (files, or directories with an `index.ts` / `index.js`), loaded in order. */ extensions: string[] - /** The project directory extensions see as `ctx.cwd` (default: the runtime's). */ + /** The project directory extensions see as `ctx.cwd`; relative paths resolve against it (default: the runtime's). */ cwd?: string /** yoagent tool name → the pi tool name policies see (merged over TOOL_NAMES). */ toolNames?: Record - /** Fail loading when an extension registers something that does not map. */ + /** yoagent built-ins the host left out, so pi tools of the same name are the ones that run. */ + withoutBuiltins?: string[] + /** Deciding events (DECIDING_EVENTS) the host accepts going unenforced. */ + allowUnmapped?: string[] + /** Fail loading when an extension registers anything else that does not map. */ strict?: boolean } @@ -94,6 +119,18 @@ const TOOL_NAMES: Record = { list_files: 'find', } +/** The arguments yoagent's built-ins take: a rewrite to any other field cannot apply. */ +const YOAGENT_KEYS = new Map( + Object.entries({ + bash: ['command'], + read_file: ['path', 'offset', 'limit'], + write_file: ['path', 'content'], + edit_file: ['path', 'old_text', 'new_text'], + search: ['pattern', 'path', 'include', 'case_sensitive'], + list_files: ['path', 'pattern', 'max_depth'], + }).map(([tool, keys]) => [tool, new Set(keys)]), +) + type Args = Record /** @@ -101,49 +138,75 @@ type Args = Record * built-in: only those calls are translated (a pi tool that is itself named * `edit` keeps its arguments as they are). */ -const ARGS = new Map(Object.entries({ - edit_file: { - toPi: ({ old_text, new_text, ...rest }) => ({ ...rest, edits: [{ oldText: old_text, newText: new_text }] }), - fromPi: ({ edits, ...rest }) => { - const list = edits as { oldText?: unknown; newText?: unknown }[] | undefined - if (!Array.isArray(list) || list.length !== 1) { - throw new Error("a pi extension rewrote `edits` into something other than one edit, which yoagent's edit_file cannot run") - } - return { ...rest, old_text: list[0].oldText, new_text: list[0].newText } +const ARGS = new Map( + Object.entries({ + edit_file: { + toPi: ({ old_text, new_text, ...rest }: Args) => ({ ...rest, edits: [{ oldText: old_text, newText: new_text }] }), + fromPi: ({ edits, ...rest }: Args) => { + const list = edits as { oldText?: unknown; newText?: unknown }[] | undefined + if (!Array.isArray(list) || list.length !== 1) { + throw new Error("a pi extension rewrote `edits` into something other than one edit, which yoagent's edit_file cannot run") + } + return { ...rest, old_text: list[0].oldText, new_text: list[0].newText } + }, }, - }, - search: { - toPi: ({ include, case_sensitive, ...rest }) => ({ - ...rest, - ...(include === undefined ? {} : { glob: include }), - // yoagent's search is case-insensitive unless asked; pi's grep is case-sensitive unless asked. - ignoreCase: !(case_sensitive ?? false), - }), - fromPi: ({ glob, ignoreCase, ...rest }) => ({ - ...rest, - ...(glob === undefined ? {} : { include: glob }), - case_sensitive: ignoreCase === false, - }), - }, - list_files: { - // pi's `find` requires a pattern; yoagent's list_files has an optional one. - toPi: ({ pattern, ...rest }) => ({ ...rest, pattern: pattern ?? '*' }), - fromPi: ({ pattern, ...rest }) => (pattern === '*' ? rest : { ...rest, pattern }), - }, -} as Record)) + search: { + toPi: ({ include, case_sensitive, ...rest }: Args) => ({ + ...rest, + ...(include === undefined ? {} : { glob: include }), + // yoagent's search is case-insensitive unless asked; pi's grep is case-sensitive unless asked. + ignoreCase: !(case_sensitive ?? false), + }), + fromPi: ({ glob, ignoreCase, ...rest }: Args) => ({ + ...rest, + ...(glob === undefined ? {} : { include: glob }), + case_sensitive: ignoreCase === false, + }), + }, + list_files: { + // pi's `find` requires a pattern; yoagent's list_files has an optional one. + toPi: ({ pattern, ...rest }: Args) => ({ ...rest, pattern: pattern ?? '*' }), + fromPi: ({ pattern, ...rest }: Args) => (pattern === '*' ? rest : { ...rest, pattern }), + }, + }), +) /** What `before_agent_start` handlers see as `event.systemPrompt`. */ const PROMPT_MARK = '\u0000yoagent-system-prompt\u0000' -/** The events this adapter fires; registering any other is reported. */ -const MAPPED_EVENTS = new Set(['tool_call', 'tool_result', 'before_agent_start', 'session_start', 'session_shutdown']) +/** The events this adapter fires. */ +const MAPPED_EVENTS = new Set(['tool_call', 'tool_result', 'input', 'before_agent_start', 'session_start', 'session_shutdown']) + +/** Unfired events whose handlers would decide or rewrite something yoagent does: they refuse the load. */ +const DECIDING_EVENTS = new Set([ + 'context', + 'context_with_system', + 'message_end', + 'before_provider_request', + 'before_provider_headers', +]) + +/** pi runtime actions with no yoagent counterpart. */ +const UNSUPPORTED_ACTIONS = [ + 'sendMessage', + 'sendUserMessage', + 'appendEntry', + 'setSessionName', + 'getSessionName', + 'setLabel', + 'getSettings', + 'getCommands', + 'getThinkingLevel', + 'setThinkingLevel', +] /** Any theme call returns its text unstyled (`theme.fg('dim', text)` → text). */ const PLAIN_THEME = new Proxy({} as Record, { - get: () => (...args: unknown[]) => args[args.length - 1], + get: (_target, key) => + key === 'then' ? undefined : key === 'name' ? 'plain' : (...args: unknown[]) => args[args.length - 1], }) -/** pi's print-mode UI: nothing to show, no one to ask. */ +/** pi's print-mode UI (its `noOpUIContext`): nothing to show, no one to ask. */ const NO_UI = new Proxy( { select: async () => undefined, @@ -152,6 +215,7 @@ const NO_UI = new Proxy( editor: async () => undefined, custom: async () => undefined, notify: (message: string, level?: string) => console.warn(`[pi ${level ?? 'info'}] ${message}`), + onTerminalInput: () => () => {}, getEditorText: () => '', getAllThemes: () => [], getTheme: () => undefined, @@ -160,8 +224,9 @@ const NO_UI = new Proxy( theme: PLAIN_THEME, } as Record, { - // Every other UI call (setStatus, setWidget, ...) does nothing, as in pi. - get: (target, key) => (key in target ? target[key as string] : () => undefined), + // Every other UI call (setStatus, setWidget, ...) does nothing, as in pi; + // `then` stays undefined so the object is not mistaken for a promise. + get: (target, key) => (key in target ? target[key as string] : key === 'then' ? undefined : () => undefined), }, ) @@ -203,9 +268,13 @@ interface PiRuntime { pendingNativeProviderRegistrations?: { provider: { id: string }; extensionPath: string }[] pendingVirtualModelRegistrations?: { definition: { provider: string; id: string }; extensionPath: string }[] mcpServers?: { list(): { name: string; extensionPath?: string }[] } + [action: string]: unknown } -/** pi's loader, by file: the package exports only the discovering variant, which also loads ~/.pi. */ +/** + * pi's loader, by file: the package exports only `discoverAndLoadExtensions`, + * which also loads `/.pi/extensions` and `~/.pi/agent/extensions`. + */ async function piLoader(): Promise<{ loadExtensions(paths: string[], cwd: string): Promise<{ extensions: PiExtension[] @@ -224,6 +293,17 @@ function text(content: Block[] | undefined): string { return (content ?? []).map((block) => (block.type === 'text' ? (block.text ?? '') : `[${block.type} block]`)).join('\n') } +/** JSON with object keys sorted: arguments cross serde_json, which may reorder them. */ +function canon(value: unknown): string { + return JSON.stringify(value, (_key, v) => + v && typeof v === 'object' && !Array.isArray(v) + ? Object.fromEntries(Object.entries(v as Args).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) + : v, + ) +} + +const message = (error: unknown) => (error instanceof Error ? error.message : String(error)) + export default definePlugin({ inject: ['yoagent'], config: { @@ -234,6 +314,8 @@ export default definePlugin({ extensions: { type: 'array', items: { type: 'string' } }, cwd: { type: 'string' }, toolNames: { type: 'object', additionalProperties: { type: 'string' } }, + withoutBuiltins: { type: 'array', items: { type: 'string' } }, + allowUnmapped: { type: 'array', items: { type: 'string' } }, strict: { type: 'boolean' }, }, }, @@ -241,6 +323,8 @@ export default definePlugin({ const yoagent = ctx.use('yoagent') const cwd = config.cwd ?? process.cwd() const names = new Map(Object.entries({ ...TOOL_NAMES, ...config.toolNames })) + const withoutBuiltins = new Set(config.withoutBuiltins ?? []) + const allowUnmapped = new Set(config.allowUnmapped ?? []) const { SessionManager } = await import('@earendil-works/pi-coding-agent') const { validateToolArguments } = await import('@earendil-works/pi-ai') @@ -250,11 +334,14 @@ export default definePlugin({ } for (const w of loaded.warnings ?? []) console.warn(`[pi] ${short(w.path)}: ${w.warning}`) const extensions = loaded.extensions + const runtime = loaded.runtime - const report = (message: string) => { - if (config.strict) throw new Error(`pi extensions use what does not map — ${message}`) - console.warn(`[pi] ${message}`) - } + /** + * Set when something that would have to be enforced turns up after load: + * from then on every tool call is denied, every prompt rejected, every + * run stopped. + */ + let refusal: string | undefined const sessionManager = SessionManager.inMemory(cwd) const context = (signal?: AbortSignal, systemPrompt = '') => ({ @@ -268,6 +355,7 @@ export default definePlugin({ scopedModels: [], signal, isIdle: () => signal === undefined, + // pi's own default is true; this adapter loads only the files it is given, never a project's `.pi`. isProjectTrusted: () => false, hasPendingMessages: () => false, getContextUsage: () => undefined, @@ -294,16 +382,6 @@ export default definePlugin({ const handlers = (event: string) => extensions.flatMap((ext) => (ext.handlers.get(event) ?? []).map((fn) => ({ ext, fn }))) - const fire = async (event: string, payload: unknown) => { - for (const { ext, fn } of handlers(event)) { - try { - await fn(payload, context()) - } catch (error) { - console.warn(`[pi] ${short(ext.path)} ${event}: ${error}`) - } - } - } - /** Every registered tool, the first registration of a name winning (pi's `getAllRegisteredTools`). */ const registered = () => { const byName = new Map() @@ -314,61 +392,122 @@ export default definePlugin({ } return byName } - /** The ones pi would activate, so the ones the model is offered. */ - const offered = () => + + // pi.setActiveTools: an allowlist of pi names (yoagent's built-ins under TOOL_NAMES). + let active: Set | undefined + /** The tools pi would offer: activated on registration, and in the allowlist if one was set. */ + const available = () => new Map( - [...registered()].filter(([, tool]) => { + [...registered()].filter(([name, tool]) => { const exposure = tool.exposure ?? 'direct' - return (exposure === 'direct' || exposure === 'model-only') && tool.defaultActive !== false + const activated = (exposure === 'direct' || exposure === 'model-only') && tool.defaultActive !== false + return activated && (!active || active.has(name)) }), ) - const checkUnmapped = () => { - const unmapped: string[] = [] + // The runtime actions: tool activation is pi's to decide here; the rest has no counterpart. + runtime.setActiveTools = (toolNames: string[]) => { + active = new Set(toolNames) + } + runtime.getActiveTools = () => + active ? [...active] : [...new Set([...names.values(), ...available().keys()])] + runtime.getAllTools = () => [ + ...[...names].map(([yo, pi]) => ({ + name: pi, + description: `yoagent's ${yo}`, + parameters: { type: 'object' }, + exposure: 'direct', + sourceInfo: { path: 'yoagent' }, + })), + ...[...registered().values()].map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + promptGuidelines: tool.promptGuidelines, + exposure: tool.exposure ?? 'direct', + sourceInfo: { path: 'pi extension' }, + })), + ] + for (const action of UNSUPPORTED_ACTIONS) { + runtime[action] = () => { + throw new Error(`pi.${action}() is not available in yoagent`) + } + } + runtime.setModel = () => Promise.reject(new Error('pi.setModel() is not available in yoagent')) + + // What does not map: reported once each; deciding events refuse. + const reported = new Set() + const checkUnmapped = (atLoad: boolean) => { + const deciding: string[] = [] + const ignored: string[] = [] for (const ext of extensions) { - const what = [ - ...[...ext.handlers.keys()].filter((e) => !MAPPED_EVENTS.has(e)).map((e) => `event "${e}"`), - ...[...ext.commands.keys()].map((c) => `command /${c}`), - ...[...ext.shortcuts.keys()].map((s) => `shortcut ${s}`), - ...[...ext.flags.keys()].map((f) => `flag --${f}`), + const own = (what: string) => `${short(ext.path)}: ${what}` + for (const event of ext.handlers.keys()) { + if (MAPPED_EVENTS.has(event)) continue + if (DECIDING_EVENTS.has(event) && !allowUnmapped.has(event)) deciding.push(own(`event "${event}"`)) + else ignored.push(own(`event "${event}"`)) + } + ignored.push( + ...[...ext.commands.keys()].map((c) => own(`command /${c}`)), + ...[...ext.shortcuts.keys()].map((s) => own(`shortcut ${s}`)), + ...[...ext.flags.keys()].map((f) => own(`flag --${f}`)), ...(ext.messageRenderers.size + (ext.toolRenderers?.length ?? 0) + (ext.entryRenderers?.size ?? 0) > 0 - ? ['renderers'] + ? [own('renderers')] : []), - ...(loaded.runtime.pendingProviderRegistrations ?? []) + ...(runtime.pendingProviderRegistrations ?? []) .filter((p) => p.extensionPath === ext.path) - .map((p) => `model provider ${p.name}`), - ...(loaded.runtime.pendingNativeProviderRegistrations ?? []) + .map((p) => own(`model provider ${p.name}`)), + ...(runtime.pendingNativeProviderRegistrations ?? []) .filter((p) => p.extensionPath === ext.path) - .map((p) => `model provider ${p.provider.id}`), - ...(loaded.runtime.pendingVirtualModelRegistrations ?? []) + .map((p) => own(`model provider ${p.provider.id}`)), + ...(runtime.pendingVirtualModelRegistrations ?? []) .filter((v) => v.extensionPath === ext.path) - .map((v) => `virtual model ${v.definition.provider}/${v.definition.id}`), - ...(loaded.runtime.mcpServers?.list() ?? []) + .map((v) => own(`virtual model ${v.definition.provider}/${v.definition.id}`)), + ...(runtime.mcpServers?.list() ?? []) .filter((s) => s.extensionPath === ext.path) - .map((s) => `MCP server ${s.name}`), - ] - if (what.length > 0) unmapped.push(`${short(ext.path)}: ${what.join(', ')}`) + .map((s) => own(`MCP server ${s.name}`)), + ) + } + const fresh = (list: string[]) => list.filter((what) => !reported.has(what) && reported.add(what)) + const newDeciding = fresh(deciding) + const newIgnored = fresh(ignored) + if (newDeciding.length > 0) { + const text = + `pi extensions handle events yoagent never fires, which would decide or rewrite what the agent does: ` + + `${newDeciding.join('; ')} (list an event in allowUnmapped to accept that it goes unenforced)` + if (atLoad) throw new Error(text) + refusal = `the pi extensions adapter stopped: ${text}` + console.warn(`[pi] ${refusal}`) + } + 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}`) + console.warn(`[pi] ${text}`) } - if (unmapped.length > 0) report(`not available in yoagent, ignored: ${unmapped.join('; ')}`) } - // A pi tool named exactly like a yoagent tool: checked at load, so `strict` refuses the load. - const shadowWarned = new Set() - const checkShadowing = (strict: boolean) => { - for (const name of offered().keys()) { - if (!names.has(name) || shadowWarned.has(name)) continue - shadowWarned.add(name) - const message = - `pi tool "${name}" is named like yoagent's own tool. If the host also installs that one, yoagent's runs ` + - `and the pi tool never does, while the adapter still prepares and validates those calls with the pi tool's ` + - `schema — leave yoagent's out` - if (strict) report(message) - else console.warn(`[pi] ${message}`) + // A pi tool named exactly like a yoagent built-in: if the host also installs that one, it is + // the one that runs (yoagent's own tools win the merge), unjudged as the pi tool. + const checkShadowing = (atLoad: boolean) => { + for (const name of available().keys()) { + if (!names.has(name) || withoutBuiltins.has(name) || reported.has(`shadow:${name}`)) continue + reported.add(`shadow:${name}`) + const text = + `a pi extension's tool "${name}" is named like yoagent's built-in "${name}"; if the host installs that ` + + `one too, it runs instead of the pi tool. Leave yoagent's out and list it in withoutBuiltins` + if (atLoad) throw new Error(text) + refusal = `the pi extensions adapter stopped: ${text}` + console.warn(`[pi] ${refusal}`) } } // Notes from `before_agent_start`, computed once per run. const notes = new Map>() + // Runs a `{ block, terminate: true }` asked to stop. + const terminated = new Map() + // The arguments the policies approved, per run and call: `call_tool` runs only these. + const judged = new Map() + const callKey = (call: ToolCall) => `${call.run_id}/${call.call_id}` const startNote = async (prompt: string, signal: AbortSignal) => { let systemPrompt = PROMPT_MARK @@ -382,20 +521,20 @@ export default definePlugin({ const event = { type: 'before_agent_start', prompt, systemPrompt, systemPromptOptions: options } try { const result = (await fn(event, context(signal, systemPrompt))) as - | { systemPrompt?: string; message?: unknown } + | { systemPrompt?: unknown; message?: unknown } | undefined if (result?.message !== undefined) { - console.warn(`[pi] ${short(ext.path)} before_agent_start: its message skipped (yoagent cannot inject one)`) + console.warn(`[pi] ${short(ext.path)} before_agent_start: its message dropped (yoagent cannot inject one)`) } if (result?.systemPrompt !== undefined) { - if (!result.systemPrompt.includes(PROMPT_MARK)) { + if (typeof result.systemPrompt !== 'string' || !result.systemPrompt.includes(PROMPT_MARK)) { throw new Error('replaced the system prompt; yoagent only takes text added to it') } systemPrompt = result.systemPrompt } } catch (error) { // As in pi: one handler's failure is reported, the others still count. - console.warn(`[pi] ${short(ext.path)} before_agent_start, skipped: ${error instanceof Error ? error.message : error}`) + console.warn(`[pi] ${short(ext.path)} before_agent_start skipped, so it does not apply: ${message(error)}`) } } const added = systemPrompt.split(PROMPT_MARK).map((part) => part.trim()).filter(Boolean) @@ -403,20 +542,39 @@ export default definePlugin({ } // Before registering: tools an extension adds at session start are offered from the first run. - await fire('session_start', { type: 'session_start', reason: 'startup' }) - // Registered first, so a `strict` refusal below still shuts the extensions down. - ctx.effect(() => fire('session_shutdown', { type: 'session_shutdown', reason: 'quit' })) + const started: string[] = [] + for (const { ext, fn } of handlers('session_start')) { + try { + await fn({ type: 'session_start', reason: 'startup' }, context()) + } catch (error) { + started.push(`${short(ext.path)}: ${message(error)}`) + } + } + // Registered first, so a refusal below still shuts the extensions down. + ctx.effect(async () => { + for (const { ext, fn } of handlers('session_shutdown')) { + try { + await fn({ type: 'session_shutdown', reason: 'quit' }, context()) + } catch (error) { + console.warn(`[pi] ${short(ext.path)} session_shutdown: ${message(error)}`) + } + } + }) + if (started.length > 0) { + // An extension whose setup failed may be missing its policies. + throw new Error(`pi extensions failed in session_start: ${started.join('; ')}`) + } // After session_start, so what it registered is checked too. - checkUnmapped() - checkShadowing(config.strict === true) + checkUnmapped(true) + checkShadowing(true) ctx.effect( yoagent.register(config.name ?? 'pi-extensions', { async tools(): Promise { - // Tools registered after load: warned only (a run is not the place to refuse). + // Registrations made since load: reported now (a run cannot refuse the load). + checkUnmapped(false) checkShadowing(false) - const tools = offered() - return [...tools.values()].map((tool) => ({ + return [...available().values()].map((tool) => ({ name: tool.name, label: tool.label ?? null, description: [tool.description ?? '', ...(tool.promptGuidelines ?? []).map((g) => `- ${g}`)] @@ -427,28 +585,41 @@ export default definePlugin({ }, async call_tool(call: ToolCall & Cancellable): Promise { - const tool = offered().get(call.tool) - if (!tool) return { text: `pi tool ${call.tool} is no longer registered`, is_error: true } + const tool = available().get(call.tool) + if (!tool) return { text: `pi tool ${call.tool} is not available`, is_error: true } + const approved = judged.get(callKey(call)) + judged.delete(callKey(call)) + if (approved === undefined || canon(call.args) !== approved) { + return { + text: `not run: the arguments of ${call.tool} changed after pi's policies judged them`, + is_error: true, + } + } try { - // Prepared and validated in before_tool; as in pi, execute gets what the policies left. const out = await tool.execute(call.call_id, call.args, call.signal, undefined, toolContext(call.signal)) return { text: text(out.content), details: out.details ?? null, is_error: out.isError === true } } catch (error) { - return { text: error instanceof Error ? error.message : String(error), is_error: true } + return { text: message(error), is_error: true } } }, async before_tool(call: ToolCall & Cancellable) { - const piTools = offered() + if (refusal) return { deny: refusal } + const piTools = available() const own = piTools.get(call.tool) - // A yoagent built-in whose pi counterpart an extension overrides: the model must use the override. const counterpart = names.get(call.tool) + // A yoagent built-in whose pi counterpart an extension overrides: the model must use the override. if (!own && counterpart && counterpart !== call.tool && piTools.has(counterpart)) { return { deny: `a pi extension replaces this tool with "${counterpart}"; call "${counterpart}" instead` } } + const toolName = own ? call.tool : (counterpart ?? call.tool) + if (active && !active.has(toolName)) { + return { deny: `"${toolName}" is not an active tool (a pi extension narrowed them with setActiveTools)` } + } + const policies = handlers('tool_call') + const shape = own ? undefined : ARGS.get(call.tool) let input: Args - let original: Args = call.args - const raw = JSON.stringify(call.args) + let base: Args = call.args if (own) { // As pi's agent loop: prepare, validate, then the policies judge the validated arguments. try { @@ -457,43 +628,59 @@ export default definePlugin({ const prepared = own.prepareArguments ? (own.prepareArguments(copy) as Args) : copy input = validateToolArguments(own as never, { name: own.name, arguments: prepared } as never) as Args } catch (error) { - return { deny: error instanceof Error ? error.message : String(error) } + return { deny: message(error) } } } else { - const shape = ARGS.get(call.tool) - original = shape ? shape.toPi(call.args) : call.args - input = structuredClone(original) + // A relative path is resolved where the extensions look (`ctx.cwd`), so the tool acts there. + if (policies.length > 0 && typeof base.path === 'string' && !isAbsolute(base.path)) { + base = { ...base, path: resolve(cwd, base.path) } + } + input = structuredClone(shape ? shape.toPi(base) : base) } - const toolName = own ? call.tool : (counterpart ?? call.tool) - const before = JSON.stringify(input) - for (const { ext, fn } of handlers('tool_call')) { + const before = canon(input) + for (const { ext, fn } of policies) { const event = { type: 'tool_call', toolCallId: call.call_id, toolName, input } - let result: { block?: boolean; reason?: string } | undefined + let result: { block?: boolean; reason?: string; terminate?: boolean } | undefined try { result = (await fn(event, context(call.signal))) as typeof result } catch (error) { // As in pi: a failing tool_call handler blocks the call. - return { deny: `pi extension ${short(ext.path)} failed: ${error}` } + return { deny: `pi extension ${short(ext.path)} failed: ${message(error)}` } + } + if (result?.block) { + const reason = result.reason ?? `blocked by pi extension ${short(ext.path)}` + if (result.terminate === true) terminated.set(call.run_id, reason) + return { deny: reason } } - if (result?.block) return { deny: result.reason ?? `blocked by pi extension ${short(ext.path)}` } } if (own) { + judged.set(callKey(call), canon(input)) // Prepared or coerced arguments count as a rewrite too. - return JSON.stringify(input) === raw ? undefined : { args: input } + return canon(input) === canon(call.args) ? undefined : { args: input } } - if (JSON.stringify(input) === before) return + if (canon(input) === before && base === call.args) return + let out: Args try { - const shape = ARGS.get(call.tool) - return { args: shape ? shape.fromPi(input) : input } + out = shape ? shape.fromPi(input) : input } catch (error) { - return { deny: String(error) } + return { deny: message(error) } } + const keys = YOAGENT_KEYS.get(call.tool) + if (keys) { + for (const [key, value] of Object.entries(out)) { + if (!keys.has(key) && canon(value) !== canon(base[key])) { + return { deny: `a pi extension set "${key}", which yoagent's ${call.tool} does not have` } + } + } + } + return { args: out } }, async after_tool(call: ToolCall & Cancellable, output: ToolOutput) { + if (refusal) throw new Error(refusal) const editors = handlers('tool_result') if (editors.length === 0) return - const own = offered().has(call.tool) + const own = available().has(call.tool) const shape = own ? undefined : ARGS.get(call.tool) const event = { type: 'tool_result', @@ -506,6 +693,7 @@ export default definePlugin({ isError: output.is_error, } const changed = { content: false, details: false, isError: false } + const failed: string[] = [] for (const { ext, fn } of editors) { try { const result = (await fn(event, context(call.signal))) as @@ -524,10 +712,13 @@ export default definePlugin({ changed.isError = true } } catch (error) { - // As in pi: a failing tool_result handler is reported, the chain goes on. - console.warn(`[pi] ${short(ext.path)} tool_result: ${error}`) + failed.push(`${short(ext.path)}: ${message(error)}`) } } + if (failed.length > 0) { + // Unlike pi (which skips it): a redaction that failed must not let the raw output through. + throw new Error(`pi tool_result handlers failed, the result is withheld: ${failed.join('; ')}`) + } if (!changed.content && !changed.details && !changed.isError) return return { // Only a replaced content is sent back, as text: yoagent's edit replaces every block. @@ -537,7 +728,28 @@ export default definePlugin({ } }, + async on_input(input) { + if (refusal) return { reject: refusal } + for (const { ext, fn } of handlers('input')) { + const event = { type: 'input', text: input.text, source: 'interactive' } + let result: { action?: string } | undefined + try { + result = (await fn(event, context(input.signal))) as typeof result + } catch (error) { + // Unlike pi (which goes on): an input check that failed rejects. + return { reject: `pi extension ${short(ext.path)} failed: ${message(error)}` } + } + if (result?.action === 'handled') return { reject: `handled by pi extension ${short(ext.path)}` } + if (result?.action === 'transform') { + return { reject: `pi extension ${short(ext.path)} would rewrite the prompt, which yoagent cannot do` } + } + } + }, + async before_model(turn) { + if (refusal) return { stop: refusal } + const stop = terminated.get(turn.run_id) + if (stop) return { stop } if (handlers('before_agent_start').length === 0) return let note = notes.get(turn.run_id) if (!note) { @@ -550,6 +762,8 @@ export default definePlugin({ async finish(outcome) { notes.delete(outcome.run_id) + terminated.delete(outcome.run_id) + for (const key of judged.keys()) if (key.startsWith(`${outcome.run_id}/`)) judged.delete(key) }, }), ) diff --git a/integrations/yoagent-rutis/rel.txt b/integrations/yoagent-rutis/rel.txt new file mode 100644 index 0000000..60aecbd --- /dev/null +++ b/integrations/yoagent-rutis/rel.txt @@ -0,0 +1 @@ +here \ No newline at end of file diff --git a/integrations/yoagent-rutis/tests/pi_test.rs b/integrations/yoagent-rutis/tests/pi_test.rs index 72b401f..110eb91 100644 --- a/integrations/yoagent-rutis/tests/pi_test.rs +++ b/integrations/yoagent-rutis/tests/pi_test.rs @@ -1,7 +1,8 @@ //! pi coding-agent extensions in yoagent, end to end: the adapter -//! `plugins/pi/pi-extensions-adapter.ts` loading a fixture pi extension -//! (`plugins/pi/fixture-extension.ts`, no network) as a rutis-loader row of a -//! Node runtime. +//! `plugins/pi/pi-extensions-adapter.ts` loading pi extensions — the fixtures +//! `plugins/pi/fixture-extension.ts` and `plugins/pi/fixture-extra.ts`, and +//! small ones written to a temporary directory; no network — as a +//! rutis-loader row of a Node runtime. //! //! Needs Node 24+ and `npm ci` in `plugins/pi/`. Without them the test //! prints `SKIPPED:` and passes; `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1` (CI) makes @@ -12,7 +13,7 @@ mod common; use std::path::{Path, PathBuf}; use std::process::Command; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; use common::*; @@ -24,8 +25,10 @@ use rutis_loader::{ }; use serde_json::{json, Value}; use yoagent::provider::mock::{MockResponse, MockToolCall}; -use yoagent::tools::{BashTool, EditFileTool, ReadFileTool, SearchTool, WriteFileTool}; -use yoagent::{AgentEvent, AgentMessage, Content, Message}; +use yoagent::tools::{ + BashTool, EditFileTool, ListFilesTool, ReadFileTool, SearchTool, WriteFileTool, +}; +use yoagent::{AgentEvent, AgentMessage, Content, Message, ToolDecision}; use yoagent_rutis::RutisBridge; fn pi_dir() -> PathBuf { @@ -179,6 +182,8 @@ async fn pi_extensions_reach_a_yoagent_agent_through_the_adapter() { let env_file = project.path().join(".env"); let notes = project.path().join("notes.txt"); std::fs::write(¬es, "hello world").unwrap(); + let multi = project.path().join("multi.txt"); + std::fs::write(&multi, "one").unwrap(); let host = host(&runtime).await; host.load(json!([{ @@ -205,6 +210,11 @@ async fn pi_extensions_reach_a_yoagent_agent_through_the_adapter() { "edit_file", json!({"path": notes, "old_text": "world", "new_text": "pi"}), ), + // The policy adds a second edit, which yoagent's edit_file cannot run. + ( + "edit_file", + json!({"path": multi, "old_text": "one", "new_text": "two"}), + ), ]), text("done"), call("pi_slow", json!({})), @@ -252,6 +262,15 @@ async fn pi_extensions_reach_a_yoagent_agent_through_the_adapter() { // ...an in-place rewrite of `event.input` changes the call... let (_, text, is_error) = result("bash"); assert!(!is_error && text.contains("rewritten"), "{results:?}"); + // ...and a rewrite yoagent's tool cannot run is denied, not cut down. + let (_, text, is_error) = results + .iter() + .filter(|(n, _, _)| n == "edit_file") + .nth(1) + .cloned() + .unwrap(); + assert!(is_error && text.contains("one edit"), "{results:?}"); + assert_eq!(std::fs::read_to_string(&multi).unwrap(), "one"); // ...including edit_file's arguments, translated to pi's `edits` and back. assert!(!result("edit_file").2, "{results:?}"); assert_eq!(std::fs::read_to_string(¬es).unwrap(), "hello PI"); @@ -304,7 +323,10 @@ async fn pi_semantics_on_the_less_common_paths() { let image = project.path().join("dot.png"); std::fs::write(&image, PNG).unwrap(); std::fs::write(project.path().join("a.md"), "needle in markdown").unwrap(); - std::fs::write(project.path().join("b.txt"), "needle in text").unwrap(); + // Upper-case: found only because yoagent's search stays case-insensitive. + std::fs::write(project.path().join("b.txt"), "NEEDLE in text").unwrap(); + std::fs::create_dir(project.path().join("inner")).unwrap(); + std::fs::write(project.path().join("inner/deep.txt"), "deep").unwrap(); let host = host(&runtime).await; host.load(json!([{ @@ -341,14 +363,27 @@ async fn pi_semantics_on_the_less_common_paths() { "search", json!({"pattern": "needle", "path": project.path(), "include": "*.md"}), ), + // Fails pi's validation: `text` is required. + ("pi_echo", json!({})), + ("list_files", json!({"path": project.path()})), + ("bash", json!({"command": "echo SECRET"})), + ("bash", json!({"command": "echo BREAK"})), + ("bash", json!({"command": "echo bounded"})), + // Relative: resolved against the extensions' cwd, not the test's. + ("write_file", json!({"path": "rel.txt", "content": "here"})), + // Blocked with terminate: the run stops before its next request. + ("bash", json!({"command": "echo stop-now"})), ]), - text("done"), + text("never requested"), ]); let mut agent = agent .with_tools(vec![ Box::new(ReadFileTool::new()), Box::new(EditFileTool::new()), Box::new(SearchTool::new()), + Box::new(ListFilesTool::new()), + Box::new(BashTool::new()), + Box::new(WriteFileTool::new()), ]) .with_extension(host.bridge.extension()); let (events, results) = tokio::time::timeout(Duration::from_secs(60), run(&mut agent, "go")) @@ -371,7 +406,10 @@ async fn pi_semantics_on_the_less_common_paths() { ); // defaultActive: false is not offered, so the call fails as unknown. assert!(!seen_now[0].tools.contains(&"pi_inactive".to_string())); - assert!(result(3).2 && !result(3).1.contains("inactive ran")); + assert!( + result(3).2 && result(3).1.contains("Tool pi_inactive not found"), + "{results:?}" + ); // yoagent's edit_file is denied: an extension overrides pi's `edit`. assert!( result(4).2 && result(4).1.contains(r#"call "edit" instead"#), @@ -410,16 +448,54 @@ async fn pi_semantics_on_the_less_common_paths() { "the details edit landed" ); // search's include is grep's glob, and its default case-insensitivity is - // ignoreCase: true: the policy's rewrite to *.txt reached the tool. + // ignoreCase: true both ways: the rewrite to *.txt reached the tool, which + // still matched the upper-case NEEDLE. let (_, text, _) = result(8); assert!( text.contains("b.txt") && !text.contains("a.md"), "{results:?}" ); - // The crashing, message-returning and prompt-replacing before_agent_start - // handlers are skipped; the good one still adds its text. - // A message is skipped but its handler's addition kept; a handler after - // the failing ones still counts. + // A pi tool's arguments are validated before the policies: a missing field denies. + assert!(result(9).2 && result(9).1.contains("text"), "{results:?}"); + // list_files as pi's find: the required pattern defaulted to '*', and the + // policy's path rewrite reached the tool. + let (_, text, is_error) = result(10); + assert!( + !is_error && text.contains("deep.txt") && !text.contains("a.md"), + "{results:?}" + ); + // A content-replacing tool_result edit applies to yoagent's built-ins too. + let (_, text, _) = result(11); + assert!( + text.contains("[redacted]") && !text.contains("SECRET"), + "{results:?}" + ); + // A tool_result handler that throws withholds the result. + let (_, text, _) = result(12); + assert!( + text.contains("withheld") && !text.contains("BREAK"), + "{results:?}" + ); + // A rewrite to a field yoagent's bash does not have is denied. + assert!( + result(13).2 && result(13).1.contains(r#""timeout""#), + "{results:?}" + ); + // The relative path was resolved where the policies looked. + assert!(!result(14).2, "{results:?}"); + assert_eq!( + std::fs::read_to_string(project.path().join("rel.txt")).unwrap(), + "here" + ); + // terminate: true denied the call and stopped the run before its next request. + assert!( + result(15).2 && result(15).1.contains("stopping the run"), + "{results:?}" + ); + assert_eq!(seen_now.len(), 1, "the run stopped: {seen_now:?}"); + // The crashing and prompt-replacing before_agent_start handlers are + // skipped; the message-returning one loses only its message; a handler + // after them still counts. let note = &seen_now[0].last_user; for kept in [ "Fixture rules: answer in one line.", @@ -432,6 +508,21 @@ async fn pi_semantics_on_the_less_common_paths() { host.root.shutdown().await.unwrap(); } +/// Writes a pi extension to `dir` and returns its path. +fn extension(dir: &Path, name: &str, source: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, source).unwrap(); + path +} + +fn adapter_row(config: Value) -> Value { + json!([{ + "id": "pi", + "name": pi_dir().join("pi-extensions-adapter.ts"), + "config": config, + }]) +} + #[tokio::test(flavor = "multi_thread")] async fn strict_refuses_extensions_that_use_what_does_not_map() { let Some(runtime) = node_runtime() else { @@ -439,14 +530,10 @@ async fn strict_refuses_extensions_that_use_what_does_not_map() { }; let host = host(&runtime).await; let failures = host - .try_load(json!([{ - "id": "pi", - "name": pi_dir().join("pi-extensions-adapter.ts"), - "config": { - "extensions": [pi_dir().join("fixture-extension.ts")], - "strict": true, - }, - }])) + .try_load(adapter_row(json!({ + "extensions": [pi_dir().join("fixture-extension.ts")], + "strict": true, + }))) .await; assert!( failures.contains("command /fixture"), @@ -457,14 +544,14 @@ async fn strict_refuses_extensions_that_use_what_does_not_map() { } #[tokio::test(flavor = "multi_thread")] -async fn strict_refuses_a_pi_tool_named_like_a_yoagent_tool() { +async fn a_pi_tool_named_like_a_builtin_needs_the_host_to_leave_it_out() { let Some(runtime) = node_runtime() else { return; }; let dir = tempfile::tempdir().unwrap(); - let ext = dir.path().join("sandboxed-bash.ts"); - std::fs::write( - &ext, + let ext = extension( + dir.path(), + "sandboxed-bash.ts", r#"import { Type } from 'typebox' export default function (pi) { pi.registerTool({ @@ -474,20 +561,248 @@ export default function (pi) { }) } "#, - ) - .unwrap(); + ); + // Without withoutBuiltins, the load is refused (no strict needed). let host = host(&runtime).await; let failures = host - .try_load(json!([{ - "id": "pi", - "name": pi_dir().join("pi-extensions-adapter.ts"), - "config": { "extensions": [ext], "strict": true }, - }])) + .try_load(adapter_row(json!({ "extensions": [ext] }))) .await; assert!( - failures.contains(r#"pi tool \"bash\" is named like yoagent's own tool"#) - || failures.contains(r#"pi tool "bash" is named like yoagent's own tool"#), - "a same-name tool fails a strict load: {failures}" + failures.contains("withoutBuiltins") && failures.contains("bash"), + "{failures}" + ); + assert!(host.bridge.registry().handlers().is_empty()); + host.root.shutdown().await.unwrap(); + + // With it, the pi tool is the agent's bash. + let host = self::host(&runtime).await; + host.load(adapter_row( + json!({ "extensions": [ext], "withoutBuiltins": ["bash"] }), + )) + .await; + let (agent, _) = agent(vec![call("bash", json!({"command": "ls"})), text("done")]); + let mut agent = agent.with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "go").await; + assert_eq!( + results, + vec![("bash".into(), "sandboxed".into(), false)], + "{results:?}" + ); + host.root.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn what_could_leave_a_policy_unenforced_refuses_the_load() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let cases = [ + ( + "throws.ts", + "export default function () { throw new Error('factory crashed') }\n", + "factory crashed", + ), + ( + "unsupported-start.ts", + "export default function (pi) { pi.on('session_start', () => pi.appendEntry('x', {})) }\n", + "session_start", + ), + ( + "context.ts", + "export default function (pi) { pi.on('context', () => ({ messages: [] })) }\n", + "allowUnmapped", + ), + ]; + for (file, source, want) in cases { + let ext = extension(dir.path(), file, source); + let host = host(&runtime).await; + // Loaded next to a working extension: nothing loads, not just the bad one. + let failures = host + .try_load(adapter_row(json!({ + "extensions": [pi_dir().join("fixture-extension.ts"), ext], + }))) + .await; + assert!(failures.contains(want), "{file}: {failures}"); + assert!(host.bridge.registry().handlers().is_empty(), "{file}"); + host.root.shutdown().await.unwrap(); + } + + // A deciding event the host accepts going unenforced. + let ext = dir.path().join("context.ts"); + let host = host(&runtime).await; + host.load(adapter_row( + json!({ "extensions": [ext], "allowUnmapped": ["context"] }), + )) + .await; + host.root.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn set_active_tools_narrows_what_the_agent_can_call() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let readme = dir.path().join("README.md"); + std::fs::write(&readme, "readme").unwrap(); + let ext = extension( + dir.path(), + "plan-mode.ts", + r#"import { Type } from 'typebox' +const tool = (name) => ({ + name, label: name, description: name, parameters: Type.Object({}), + async execute() { return { content: [{ type: 'text', text: `${name} ran` }], details: undefined } }, +}) +export default function (pi) { + pi.registerTool(tool('pi_x')) + pi.registerTool(tool('pi_y')) + pi.on('session_start', () => pi.setActiveTools(['read', 'pi_x'])) +} +"#, + ); + let host = host(&runtime).await; + host.load(adapter_row(json!({ "extensions": [ext] }))).await; + let (agent, seen) = agent(vec![ + calls(&[ + ("pi_x", json!({})), + ("pi_y", json!({})), + ("bash", json!({"command": "echo hi"})), + ("read_file", json!({"path": readme})), + ]), + text("done"), + ]); + let mut agent = agent + .with_tools(vec![ + Box::new(BashTool::new()), + Box::new(ReadFileTool::new()), + ]) + .with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "go").await; + let tools = seen.lock().unwrap()[0].tools.clone(); + assert!( + tools.contains(&"pi_x".into()) && !tools.contains(&"pi_y".into()), + "{tools:?}" + ); + assert_eq!(results[0].1, "pi_x ran", "{results:?}"); + // Not offered, and a call to it is denied as inactive. + assert!( + results[1].2 && results[1].1.contains(r#""pi_y" is not an active tool"#), + "{results:?}" + ); + // yoagent's bash is pi's `bash`, which the extension left out... + assert!( + results[2].2 && results[2].1.contains("not an active tool"), + "{results:?}" + ); + // ...while read_file is pi's `read`, which it kept. + assert!( + !results[3].2 && results[3].1.contains("readme"), + "{results:?}" + ); + host.root.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn prompt_notes_are_per_run_and_input_handlers_reject() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let ext = extension( + dir.path(), + "per-run.ts", + r#"import { Type } from 'typebox' +let calls = 0 +export default function (pi) { + pi.registerTool({ + name: 'pi_ping', label: 'ping', description: 'ping', parameters: Type.Object({}), + async execute() { return { content: [{ type: 'text', text: 'pong' }], details: undefined } }, + }) + pi.on('before_agent_start', (event) => { + calls += 1 + return { systemPrompt: `${event.systemPrompt}\nPrompt: ${event.prompt} (call ${calls})` } + }) + pi.on('input', (event) => { + if (event.text.includes('SECRET')) return { action: 'handled' } + if (event.text.includes('rewrite')) return { action: 'transform', text: 'x' } + return { action: 'continue' } + }) +} +"#, + ); + let host = host(&runtime).await; + host.load(adapter_row(json!({ "extensions": [ext] }))).await; + let (agent, seen) = agent(vec![call("pi_ping", json!({})), text("one"), text("two")]); + let mut agent = agent.with_extension(host.bridge.extension()); + run(&mut agent, "first").await; + run(&mut agent, "second").await; + let seen_now = seen.lock().unwrap().clone(); + assert_eq!(seen_now.len(), 3, "{seen_now:?}"); + // Both requests of the first run carry its note; the handler ran once per run. + for request in &seen_now[..2] { + assert!( + request.last_user.contains("Prompt: first (call 1)"), + "{seen_now:?}" + ); + } + assert!( + seen_now[2].last_user.contains("Prompt: second (call 2)"), + "{seen_now:?}" + ); + assert!( + seen_now.iter().all(|r| !r.last_user.contains('\u{0}')), + "the prompt placeholder never leaks" + ); + + // `handled` and `transform` both reject the prompt before any request. + for prompt in ["my SECRET plan", "please rewrite this"] { + let (events, _) = run(&mut agent, prompt).await; + assert!( + events + .iter() + .any(|e| matches!(e, AgentEvent::InputRejected { .. })), + "{prompt}: {events:?}" + ); + } + assert_eq!(seen.lock().unwrap().len(), 3, "no request was sent"); + host.root.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_pi_tool_runs_only_the_arguments_its_policies_judged() { + let Some(runtime) = node_runtime() else { + return; + }; + let host = host(&runtime).await; + host.load(adapter_row( + json!({ "extensions": [pi_dir().join("fixture-extension.ts")] }), + )) + .await; + // A handler registered after the adapter rewrites pi_echo's arguments. + let rewrites = Arc::new(Mutex::new(0)); + let counter = rewrites.clone(); + let later = host + .root + .plugin(plugin(handler("later").with_before_tool(move |call| { + if call.tool == "pi_echo" { + *counter.lock().unwrap() += 1; + ToolDecision::Modify(json!({"text": "tampered"})) + } else { + ToolDecision::Allow + } + }))); + wait_active(&later).await; + let (agent, _) = agent(vec![call("pi_echo", json!({"text": "hi"})), text("done")]); + let mut agent = agent.with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "go").await; + assert_eq!(*rewrites.lock().unwrap(), 1); + assert!( + results[0].2 + && results[0] + .1 + .contains("changed after pi's policies judged them"), + "{results:?}" ); host.root.shutdown().await.unwrap(); } From e769e5fc991aeb7e68c7da72bf179d982b582eb5 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 17:29:50 +0200 Subject: [PATCH 05/15] =?UTF-8?q?fix(rutis):=20pi=20adapter=20re-review=20?= =?UTF-8?q?=E2=80=94=20setActiveTools=20scope,=20built-in-only=20path=20re?= =?UTF-8?q?solution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the setActiveTools allowlist covers only tools pi knows; MCP, sub-agent and host tools are unaffected (as in pi) - as pi's _applyToolLoadout: a listed tool is available unless hidden (defaultActive ignored); one registered later that pi would activate joins the allowlist - relative paths are resolved only for yoagent's built-ins (an MCP tool's path may be a repository's) - getAllTools reports pi-shaped SourceInfo, skips withoutBuiltins and names an extension tool takes; tools() offers nothing once refused - canon compares non-integers at 15 significant digits (serde_json float round trip) - terminate documented as stricter than pi; stray rel.txt removed Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CLAUDE.md | 2 +- integrations/yoagent-rutis/README.md | 4 +- .../plugins/pi/pi-extensions-adapter.ts | 124 ++++++++++++------ integrations/yoagent-rutis/rel.txt | 1 - integrations/yoagent-rutis/tests/pi_test.rs | 32 ++++- 5 files changed, 114 insertions(+), 49 deletions(-) delete mode 100644 integrations/yoagent-rutis/rel.txt diff --git a/CLAUDE.md b/CLAUDE.md index de5692e..4fab41e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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()`) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. 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 (pi names), `getActiveTools`/`getAllTools` from it; `UNSUPPORTED_ACTIONS` (`sendMessage`, `appendEntry`, ...) 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 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. `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 (args cross serde_json). `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`/`executeTool` throw. 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"]`, 8 tests: 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/`, 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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()`) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. 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`/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `UNSUPPORTED_ACTIONS` (`sendMessage`, `appendEntry`, ...) 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`/`executeTool` throw. 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"]`, 8 tests: 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/`, 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`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 4d1f6a2..aaaa5c9 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -323,8 +323,8 @@ onto one handler, following pi 1.1.0's own runner and agent loop: | pi | yoagent | |---|---| | `pi.registerTool` | a tool, while pi would activate it (exposure `direct` / `model-only`, `defaultActive` not false, in the `setActiveTools` allowlist if one was set; the first registration of a name wins). Arguments go through `prepareArguments` and pi's validation before any policy sees them, and `execute` runs only with the arguments the policies left — a call another handler rewrote afterwards is not run. A throw or `isError` is an error result (non-text blocks become `[image block]` text) | -| `pi.setActiveTools` | an allowlist: other tools are not offered, and calls to them — yoagent's built-ins under pi's names included — are denied; `getActiveTools` / `getAllTools` answer from the same view | -| `on("tool_call")` | `before_tool` for every call. yoagent's built-ins are judged under pi's names (`bash`, `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit` with `edits`, `search`→`grep` with `glob` / `ignoreCase` (yoagent's search is case-insensitive unless asked, pi's grep the reverse), `list_files`→`find`; config `toolNames` adds more), with a relative `path` resolved against `cwd` first so the tool acts where the policy looked. `{ block }` denies (`terminate: true` also stops the run at its next model request); in-place changes to `event.input` rewrite the arguments, and one the yoagent tool cannot take (a second edit, a `timeout` on `bash`) denies the call; a throwing handler blocks, as in pi | +| `pi.setActiveTools` | an allowlist over the tools pi knows — yoagent's built-ins under pi's names and the extensions' tools: those outside it are not offered and their calls are denied. Tools pi does not know (MCP tools, sub-agents, the host's own) are not affected, as in pi. As in pi, a tool named in it is activated even when `defaultActive` is false (not when hidden), and a tool registered later that pi would activate joins it. `getActiveTools` / `getAllTools` answer from the same view | +| `on("tool_call")` | `before_tool` for every call. yoagent's built-ins are judged under pi's names (`bash`, `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit` with `edits`, `search`→`grep` with `glob` / `ignoreCase` (yoagent's search is case-insensitive unless asked, pi's grep the reverse), `list_files`→`find`; config `toolNames` adds more), with a relative `path` resolved against `cwd` first so the tool acts where the policy looked (yoagent's built-ins only: another tool's `path` may be a repository's). `{ block }` denies (`terminate: true` also stops the run at its next model request — stricter than pi, which ends only a batch whose results all set it); in-place changes to `event.input` rewrite the arguments, and one the yoagent tool cannot take (a second edit, a `timeout` on `bash`) denies the call; a throwing handler blocks, as in pi | | `on("tool_result")` | `after_tool`: content, details and isError edits, chained; only the fields a handler set are applied (a replaced content keeps only its text). A throwing handler **withholds the result** — unlike pi, which skips it: a failed redaction must not let the raw output through | | `on("input")` | `on_input`: `handled` rejects the prompt (it never reaches the agent, as in pi); `transform` and a throwing handler reject it too (yoagent cannot rewrite a prompt) | | `on("before_agent_start")` | `before_model`: the handlers run once per run, and the text they add around `event.systemPrompt` is a note on the latest user turn of every request of that run. A handler that throws, replaces the prompt or changes `systemPromptOptions` is skipped with a warning — even under `strict` (a run cannot refuse the load), so a policy that replaces the prompt (a "read-only mode") does not apply; the others still count. A returned `message` is dropped, the same handler's addition kept | diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts index fa3e438..1f7da8c 100644 --- a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -31,17 +31,24 @@ // like a yoagent built-in (pi's sandboxed `bash`) // refuses the load unless the host says it left // that built-in out (`withoutBuiltins`). -// pi.setActiveTools an allowlist: tools outside it are not offered -// and their calls (yoagent's built-ins under pi's -// names) are denied. `getActiveTools` / -// `getAllTools` answer from the same view. +// pi.setActiveTools an allowlist over the tools pi knows (yoagent's +// built-ins under pi's names, the extensions' +// tools): those outside it are not offered and +// their calls are denied; tools pi does not know +// (MCP, sub-agents, the host's own) are not +// affected, as in pi. A tool registered later that +// pi would activate joins it, as pi's registry +// refresh does. `getActiveTools` / `getAllTools` +// answer from the same view. // on("tool_call") `before_tool`, for every call of the run. yoagent's // built-ins are judged under pi's names (TOOL_NAMES), // their arguments translated both ways and a // relative `path` resolved against `cwd` first, so -// the tool acts where the policy looked. `{ block }` -// denies (`terminate: true` also stops the run at -// its next model request); in-place changes to +// the tool acts where the policy looked (built-ins +// only). `{ block }` denies (`terminate: true` also +// stops the run at its next model request — stricter +// than pi, which ends only a batch whose results all +// set it); in-place changes to // `event.input` rewrite the arguments (a field the // yoagent tool does not have denies the call); a // handler that throws blocks, as in pi. @@ -293,13 +300,18 @@ function text(content: Block[] | undefined): string { return (content ?? []).map((block) => (block.type === 'text' ? (block.text ?? '') : `[${block.type} block]`)).join('\n') } -/** JSON with object keys sorted: arguments cross serde_json, which may reorder them. */ +/** + * JSON with object keys sorted and non-integers at 15 significant digits: + * arguments cross serde_json, which may reorder keys and parse a float's + * shortest form one unit off. + */ function canon(value: unknown): string { - return JSON.stringify(value, (_key, v) => - v && typeof v === 'object' && !Array.isArray(v) + return JSON.stringify(value, (_key, v) => { + if (typeof v === 'number' && !Number.isInteger(v)) return Number(v.toPrecision(15)) + return v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.entries(v as Args).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) - : v, - ) + : v + }) } const message = (error: unknown) => (error instanceof Error ? error.message : String(error)) @@ -393,41 +405,68 @@ export default definePlugin({ return byName } - // pi.setActiveTools: an allowlist of pi names (yoagent's built-ins under TOOL_NAMES). + // pi.setActiveTools: an allowlist over the tools pi knows — yoagent's built-ins under pi's + // names and the extensions' tools. Other tools (MCP, sub-agents, the host's own) are not pi's + // to activate and stay outside it, as in pi. let active: Set | undefined - /** The tools pi would offer: activated on registration, and in the allowlist if one was set. */ - const available = () => - new Map( - [...registered()].filter(([name, tool]) => { - const exposure = tool.exposure ?? 'direct' - const activated = (exposure === 'direct' || exposure === 'model-only') && tool.defaultActive !== false - return activated && (!active || active.has(name)) - }), + /** Tool names registered when the allowlist was set; one registered later joins it as pi's registry refresh does. */ + let knownAtActivation = new Set() + const activatedOnRegistration = (tool: PiTool) => { + const exposure = tool.exposure ?? 'direct' + return (exposure === 'direct' || exposure === 'model-only') && tool.defaultActive !== false + } + /** The tools pi would offer (its `_applyToolLoadout`): activated on registration, or named in the allowlist and not hidden. */ + const available = () => { + const tools = registered() + if (active) { + for (const [name, tool] of tools) { + if (!knownAtActivation.has(name) && activatedOnRegistration(tool)) active.add(name) + knownAtActivation.add(name) + } + } + return new Map( + [...tools].filter(([name, tool]) => + active ? active.has(name) && (tool.exposure ?? 'direct') !== 'hidden' : activatedOnRegistration(tool), + ), ) + } + /** Whether a call to `toolName` (a pi name) is outside the allowlist. */ + const inactive = (toolName: string) => + active !== undefined && + !active.has(toolName) && + (registered().has(toolName) || [...names.values()].includes(toolName)) // The runtime actions: tool activation is pi's to decide here; the rest has no counterpart. runtime.setActiveTools = (toolNames: string[]) => { active = new Set(toolNames) + knownAtActivation = new Set(registered().keys()) } runtime.getActiveTools = () => active ? [...active] : [...new Set([...names.values(), ...available().keys()])] - runtime.getAllTools = () => [ - ...[...names].map(([yo, pi]) => ({ - name: pi, - description: `yoagent's ${yo}`, - parameters: { type: 'object' }, - exposure: 'direct', - sourceInfo: { path: 'yoagent' }, - })), - ...[...registered().values()].map((tool) => ({ - name: tool.name, - description: tool.description, - parameters: tool.parameters, - promptGuidelines: tool.promptGuidelines, - exposure: tool.exposure ?? 'direct', - sourceInfo: { path: 'pi extension' }, - })), - ] + runtime.getAllTools = () => { + const tools = registered() + return [ + // yoagent's built-ins as pi's (an extension's tool of the same name takes its place; one + // the host left out is not listed). + ...[...names] + .filter(([yo, pi]) => !withoutBuiltins.has(yo) && !tools.has(pi)) + .map(([yo, pi]) => ({ + name: pi, + description: `yoagent's ${yo}`, + parameters: { type: 'object' }, + exposure: 'direct', + sourceInfo: { path: `builtin:${pi}`, source: 'builtin', scope: 'user', origin: 'top-level' }, + })), + ...[...tools.values()].map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + promptGuidelines: tool.promptGuidelines, + exposure: tool.exposure ?? 'direct', + sourceInfo: { path: 'extension', source: 'extension', scope: 'temporary', origin: 'top-level' }, + })), + ] + } for (const action of UNSUPPORTED_ACTIONS) { runtime[action] = () => { throw new Error(`pi.${action}() is not available in yoagent`) @@ -574,6 +613,7 @@ export default definePlugin({ // Registrations made since load: reported now (a run cannot refuse the load). checkUnmapped(false) checkShadowing(false) + if (refusal) return [] return [...available().values()].map((tool) => ({ name: tool.name, label: tool.label ?? null, @@ -613,7 +653,7 @@ export default definePlugin({ return { deny: `a pi extension replaces this tool with "${counterpart}"; call "${counterpart}" instead` } } const toolName = own ? call.tool : (counterpart ?? call.tool) - if (active && !active.has(toolName)) { + if (inactive(toolName)) { return { deny: `"${toolName}" is not an active tool (a pi extension narrowed them with setActiveTools)` } } const policies = handlers('tool_call') @@ -631,8 +671,10 @@ export default definePlugin({ return { deny: message(error) } } } else { - // A relative path is resolved where the extensions look (`ctx.cwd`), so the tool acts there. - if (policies.length > 0 && typeof base.path === 'string' && !isAbsolute(base.path)) { + // A built-in's relative path is resolved where the extensions look (`ctx.cwd`), so the + // tool acts there. Only yoagent's own tools: an MCP tool's `path` may be a repository's. + const builtin = YOAGENT_KEYS.has(call.tool) + if (builtin && policies.length > 0 && typeof base.path === 'string' && !isAbsolute(base.path)) { base = { ...base, path: resolve(cwd, base.path) } } input = structuredClone(shape ? shape.toPi(base) : base) diff --git a/integrations/yoagent-rutis/rel.txt b/integrations/yoagent-rutis/rel.txt deleted file mode 100644 index 60aecbd..0000000 --- a/integrations/yoagent-rutis/rel.txt +++ /dev/null @@ -1 +0,0 @@ -here \ No newline at end of file diff --git a/integrations/yoagent-rutis/tests/pi_test.rs b/integrations/yoagent-rutis/tests/pi_test.rs index 110eb91..ff5770d 100644 --- a/integrations/yoagent-rutis/tests/pi_test.rs +++ b/integrations/yoagent-rutis/tests/pi_test.rs @@ -650,14 +650,22 @@ async fn set_active_tools_narrows_what_the_agent_can_call() { dir.path(), "plan-mode.ts", r#"import { Type } from 'typebox' -const tool = (name) => ({ +const tool = (name, extra = {}) => ({ name, label: name, description: name, parameters: Type.Object({}), async execute() { return { content: [{ type: 'text', text: `${name} ran` }], details: undefined } }, + ...extra, }) export default function (pi) { - pi.registerTool(tool('pi_x')) + pi.registerTool(tool('pi_x', { + // Registers a tool after setActiveTools: pi's registry refresh activates it. + async execute() { pi.registerTool(tool('pi_late')); return { content: [{ type: 'text', text: 'pi_x ran' }], details: undefined } }, + })) pi.registerTool(tool('pi_y')) - pi.on('session_start', () => pi.setActiveTools(['read', 'pi_x'])) + // Not activated on registration, but named in the allowlist: pi activates it. + pi.registerTool(tool('pi_z', { defaultActive: false })) + pi.on('session_start', () => pi.setActiveTools(['read', 'pi_x', 'pi_z'])) + // A policy, so built-in paths are resolved — but not other tools' paths. + pi.on('tool_call', () => undefined) } "#, ); @@ -669,21 +677,37 @@ export default function (pi) { ("pi_y", json!({})), ("bash", json!({"command": "echo hi"})), ("read_file", json!({"path": readme})), + // A tool pi does not know (an MCP tool, say): untouched by the + // allowlist and by the path resolution. + ("echo_args", json!({"path": "src/lib.rs"})), ]), text("done"), + text("again"), ]); let mut agent = agent .with_tools(vec![ Box::new(BashTool::new()), Box::new(ReadFileTool::new()), + Box::new(EchoArgs), ]) .with_extension(host.bridge.extension()); let (_, results) = run(&mut agent, "go").await; let tools = seen.lock().unwrap()[0].tools.clone(); assert!( - tools.contains(&"pi_x".into()) && !tools.contains(&"pi_y".into()), + tools.contains(&"pi_x".into()) + && tools.contains(&"pi_z".into()) + && !tools.contains(&"pi_y".into()), "{tools:?}" ); + assert_eq!( + results[4], + ("echo_args".into(), r#"{"path":"src/lib.rs"}"#.into(), false), + "{results:?}" + ); + // The tool pi_x registered joins the allowlist from the next run on. + run(&mut agent, "again").await; + let tools = seen.lock().unwrap()[2].tools.clone(); + assert!(tools.contains(&"pi_late".into()), "{tools:?}"); assert_eq!(results[0].1, "pi_x ran", "{results:?}"); // Not offered, and a call to it is denied as inactive. assert!( From e25caeba85a96c3664830c815389893749935547 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 17:33:12 +0200 Subject: [PATCH 06/15] =?UTF-8?q?fix(rutis):=20pi=20adapter=20=E2=80=94=20?= =?UTF-8?q?getActiveTools=20sees=20tools=20registered=20after=20setActiveT?= =?UTF-8?q?ools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi refreshes its registry on registerTool, so register + setActiveTools( getActiveTools().filter(...)) keeps the new tool; getActiveTools now refreshes the allowlist first (and omits withoutBuiltins without one). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CLAUDE.md | 2 +- .../yoagent-rutis/plugins/pi/pi-extensions-adapter.ts | 9 +++++++-- integrations/yoagent-rutis/tests/pi_test.rs | 7 ++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4fab41e..a6c1ab4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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()`) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. 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`/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `UNSUPPORTED_ACTIONS` (`sendMessage`, `appendEntry`, ...) 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`/`executeTool` throw. 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"]`, 8 tests: 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/`, 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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()`) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. 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); `UNSUPPORTED_ACTIONS` (`sendMessage`, `appendEntry`, ...) 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`/`executeTool` throw. 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"]`, 8 tests: 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/`, 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`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts index 1f7da8c..1e65a0b 100644 --- a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -441,8 +441,13 @@ export default definePlugin({ active = new Set(toolNames) knownAtActivation = new Set(registered().keys()) } - runtime.getActiveTools = () => - active ? [...active] : [...new Set([...names.values(), ...available().keys()])] + runtime.getActiveTools = () => { + // Brings in tools registered since setActiveTools, as pi's registry refresh does on registerTool. + const tools = available() + if (active) return [...active] + const builtins = [...names].filter(([yo]) => !withoutBuiltins.has(yo)).map(([, pi]) => pi) + return [...new Set([...builtins, ...tools.keys()])] + } runtime.getAllTools = () => { const tools = registered() return [ diff --git a/integrations/yoagent-rutis/tests/pi_test.rs b/integrations/yoagent-rutis/tests/pi_test.rs index ff5770d..282d579 100644 --- a/integrations/yoagent-rutis/tests/pi_test.rs +++ b/integrations/yoagent-rutis/tests/pi_test.rs @@ -658,7 +658,12 @@ const tool = (name, extra = {}) => ({ export default function (pi) { pi.registerTool(tool('pi_x', { // Registers a tool after setActiveTools: pi's registry refresh activates it. - async execute() { pi.registerTool(tool('pi_late')); return { content: [{ type: 'text', text: 'pi_x ran' }], details: undefined } }, + async execute() { + pi.registerTool(tool('pi_late')) + // pi's usual narrowing: it must keep the tool just registered. + pi.setActiveTools(pi.getActiveTools().filter((n) => n !== 'bash')) + return { content: [{ type: 'text', text: 'pi_x ran' }], details: undefined } + }, })) pi.registerTool(tool('pi_y')) // Not activated on registration, but named in the allowlist: pi activates it. From 28c656e3656ea868ddb04d5c8999bd09dbe76e61 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 18:59:08 +0200 Subject: [PATCH 07/15] ci: cache npm downloads in the yoagent-rutis languages job setup-node's npm cache, keyed on the plugins/, plugins/dsh/ and plugins/pi/ lockfiles; pi's install alone is ~185 MB. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .github/workflows/ci.yml | 7 +++++++ CLAUDE.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaac624..9a260b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -283,6 +283,13 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 + # npm's download cache (~/.npm), keyed on the three lockfiles: the + # installs below (pi's alone is ~185 MB) stop re-downloading. + cache: npm + cache-dependency-path: | + integrations/yoagent-rutis/plugins/package-lock.json + integrations/yoagent-rutis/plugins/dsh/package-lock.json + integrations/yoagent-rutis/plugins/pi/package-lock.json - uses: actions/setup-python@v5 with: python-version: "3.12" diff --git a/CLAUDE.md b/CLAUDE.md index a6c1ab4..51d766f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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()`) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. 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); `UNSUPPORTED_ACTIONS` (`sendMessage`, `appendEntry`, ...) 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`/`executeTool` throw. 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"]`, 8 tests: 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/`, 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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()`) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. 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); `UNSUPPORTED_ACTIONS` (`sendMessage`, `appendEntry`, ...) 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`/`executeTool` throw. 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"]`, 8 tests: 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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`. ### OpenAPI Integration (`openapi/`, feature-gated) From a40c3c7a09bae18e0a8f4f0d94f11953a8eb5489 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 21:19:14 +0200 Subject: [PATCH 08/15] feat(rutis): image tool results across TypeScript, Python, dsh and rutis-agent - bridge: call_tool results and after_tool edits take yoagent-shaped content blocks (text, base64 images) instead of text; validated by content_blocks - dsh adapter: dsh image blocks (attachment references) become yoagent images via the attachments service when one is loaded, else a text placeholder - rutis-agent example: a runner's {"content": [...]} value is read as blocks (dot_picture) - yoagent.d.ts ContentBlock; README, CLAUDE.md, CHANGELOG - tests: content_blocks unit, TS/Python round trip, dsh with and without a store; dsh_test no longer reads a mid-write empty abort file Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 7 ++ CLAUDE.md | 2 +- integrations/yoagent-rutis/README.md | 25 +++- .../examples/rutis-agent-tools/src/main.rs | 76 ++++++++++- .../plugins/dsh/dsh-tools-adapter.ts | 64 ++++++++-- .../plugins/dsh/fixture-attachments.ts | 22 ++++ .../plugins/dsh/fixture-tools.ts | 20 +++ .../yoagent-rutis/plugins/yoagent.d.ts | 27 +++- integrations/yoagent-rutis/src/languages.rs | 104 +++++++++++++-- integrations/yoagent-rutis/tests/dsh_test.rs | 58 ++++++++- .../yoagent-rutis/tests/languages_test.rs | 118 +++++++++++++++++- 11 files changed, 489 insertions(+), 34 deletions(-) create mode 100644 integrations/yoagent-rutis/plugins/dsh/fixture-attachments.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ed76563..1179422 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to `yoagent` are documented here. The format loosely follows [Keep a Changelog](https://keepachangelog.com/), and the project adheres to [Semantic Versioning](https://semver.org/). +## Unreleased + +### yoagent-rutis + +- **Images in tool results, across ecosystems.** TypeScript and Python handlers' `call_tool` results and `after_tool` edits take `content` blocks in yoagent's JSON shape (`{"type": "image", "data": , "mimeType"}` next to text blocks) instead of `text`, so pictures cross the bridge both ways (`after_tool` already saw `output.content`). The dsh adapter turns dsh image blocks (references into dsh's attachment store) into yoagent images through the `attachments` service when one is loaded; the rutis-agent example reads a runner's `{"content": [...]}` value as blocks (rutis-agent results are otherwise text). Rust handlers already returned full yoagent tool results. Tests: `content_blocks` unit test, TypeScript/Python round trip in `languages_test`, dsh with and without a store in `dsh_test`. +- Test fix: `dsh_test` no longer reads the abort file mid-write (empty) as the result. + ## 0.25.0 (2026-10-08) ### Added diff --git a/CLAUDE.md b/CLAUDE.md index fa62093..150cada 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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), `{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.7.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}`, `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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; self-checks). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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`); 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/` and `plugins/dsh/`, clippy per feature, docs, tests with the runtimes required, the `language_plugins` example; `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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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 (base64 charset, non-empty),mimeType (image/*)}`, nothing else) — 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.7.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), 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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; self-checks). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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]}` → 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/` and `plugins/dsh/`, clippy per feature, docs, tests with the runtimes required, the `language_plugins` example; `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`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 975b21b..4414fdd 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -209,6 +209,21 @@ def apply(ctx, config): return await res.text() } ``` +- **Images, both ways.** A tool result and an `after_tool` edit carry + `content` blocks in yoagent's JSON shape (pi's and MCP's too) instead of + `text` — `{"type": "text", "text"}` and + `{"type": "image", "data": , "mimeType": "image/…"}` — and + `after_tool` sees the output's blocks as `output.content`, so a handler + can return pictures, and keep, add or drop them when it edits a result. + Text and blocks are exclusive in one answer; anything else in `content` + (another block type, a non-`image/*` type, data that is not base64) fails + the answer. + + ```ts + async call_tool(call) { + return { content: [{ type: 'text', text: 'the chart' }, { type: 'image', data: png.toString('base64'), mimeType: 'image/png' }] } + } + ``` - **The bridge never loads plugins**: the host does, typically with [rutis-loader](https://crates.io/crates/rutis-loader) rows, and must share `yoagent` in the loader's catalog (`catalog.register_shared("yoagent")` or @@ -265,7 +280,7 @@ offers every tool in dsh's tool registry (the `tools` service of | Hook | What the adapter does | |---|---| | `tools` | `tools.schemas()` → name, description, parameters (config `tools`: an allowlist) | -| `call_tool` | `tools.execute({callId, name, arguments, signal})` with the bridge's cancel handle as dsh's `signal`: cancelling the run aborts the dsh call. `isError` → an error tool result; text blocks joined | +| `call_tool` | `tools.execute({callId, name, arguments, signal})` with the bridge's cancel handle as dsh's `signal`: cancelling the run aborts the dsh call. `isError` → an error tool result. Text blocks stay text; an image block — a reference into dsh's attachment store — becomes a yoagent image, its bytes read with the `attachments` service when one is loaded (looked up per image, so the adapter also runs without it; then, or when a read fails, the image is a text placeholder); other blocks are named | | `before_model` | the system-prompt sections dsh plugins added (the harness identity and persona slots left out, sections whose variables are unset skipped), as one note, capped at `maxNoteChars` (2000) | Load, as rows of one Node runtime whose `package.json` is `plugins/dsh/`'s: @@ -286,7 +301,8 @@ cargo run --features node --example dsh_tools -- --live # DeepSeek: DEEPSEEK_AP the adapter, runs one search, and checks that a dsh tool answered (and, scripted, that free-search's prompt section reached the model). `tests/dsh_test.rs` covers the adapter offline with a fixture dsh plugin -(`plugins/dsh/fixture-tools.ts`). +(`plugins/dsh/fixture-tools.ts`) and, for images, a stand-in attachment +store (`plugins/dsh/fixture-attachments.ts`). ### rutis-agent tools @@ -297,6 +313,11 @@ Rust rutis plugin that maps every `ToolDef` to a yoagent tool per run (results as text, failures as `ToolError`s, yoagent's cancel token passed as rutis-agent's), shown with rutis-agent's `replace_text` and a tool registered into the registry while the host runs, which the next run offers. +rutis-agent's results are text (a runner's JSON value is serialized), so +images use a convention of this adapter: a runner returning +`{"content": [blocks]}` in yoagent's block shape gives yoagent those blocks, +images included (`dot_picture` in the example); rutis-agent's own agent +still sees the JSON text. ```sh cargo run --manifest-path examples/rutis-agent-tools/Cargo.toml [-- --live] diff --git a/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs b/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs index ce1272d..bf06112 100644 --- a/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs +++ b/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs @@ -11,6 +11,12 @@ //! - its schema (name, description, parameters) is the tool's; //! - a result is text, a failure (rutis-agent's `ok: false`, its //! `error: ...` text) is a real `ToolError::Failed`; +//! - images: rutis-agent's results are text (a runner's JSON value is +//! serialized). By this adapter's convention a runner that returns +//! `{"content": [blocks]}` — yoagent's text and image blocks, +//! `{"type": "image", "data": , "mimeType": "image/…"}` — gives +//! yoagent those blocks, images included (rutis-agent's own agent still +//! sees the JSON text); //! - yoagent's cancel token *is* the token rutis-agent's `execute` watches, //! so cancelling the run stops the runner. The tool returns //! `ToolError::Cancelled`, but the transcript shows yoagent's "Tool result @@ -25,7 +31,9 @@ //! 2. while the host runs, a `word_count` tool is registered into //! rutis-agent's `ToolRegistry` — it appears on the agent's next run, with //! no change to the agent or the adapter (hot add); -//! 3. (scripted only) a slow tool, added the same way, is cancelled with +//! 3. (scripted only) a `dot_picture` tool, added the same way, returns a +//! picture: it reaches yoagent as an image; +//! 4. (scripted only) a slow tool, added the same way, is cancelled with //! `Agent::abort()`: its runner never finishes. //! //! The model is scripted by default; `--live` asks DeepSeek instead @@ -163,12 +171,37 @@ impl AgentTool for RegistryTool { return Err(ToolError::Failed(out.output)); } Ok(ToolResult { - content: vec![Content::Text { text: out.output }], + content: content_of(&out.output) + .unwrap_or_else(|| vec![Content::Text { text: out.output }]), details: Value::Null, }) } } +/// A runner's `{"content": [blocks]}` value (as rutis-agent serialized it), +/// read back as yoagent content: only text and image blocks, and only when +/// the whole value is that shape — any other output stays text. +fn content_of(output: &str) -> Option> { + if !output.starts_with('{') { + return None; + } + let mut value: serde_json::Map = serde_json::from_str(output).ok()?; + if value.len() != 1 { + return None; + } + let blocks: Vec = serde_json::from_value(value.remove("content")?).ok()?; + blocks + .iter() + .all(|b| match b { + Content::Text { .. } => true, + Content::Image { data, mime_type } => { + !data.is_empty() && mime_type.starts_with("image/") + } + _ => false, + }) + .then_some(blocks) +} + // ── The host ──────────────────────────────────────────────────── /// A tool registered into rutis-agent's registry while the host runs. @@ -192,6 +225,26 @@ fn word_count_tool() -> ToolDef { ) } +/// A 1×1 PNG, base64. +const DOT_PNG: &str = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=="; + +/// A tool returning a picture: text and an image, in the content-block +/// convention this adapter reads. +fn dot_picture_tool() -> ToolDef { + ToolDef::new( + "dot_picture", + "Shows a picture of a dot.", + json!({"type": "object", "properties": {}}), + |_| async move { + Ok(json!({"content": [ + {"type": "text", "text": "a dot"}, + {"type": "image", "data": DOT_PNG, "mimeType": "image/png"}, + ]})) + }, + ) +} + /// A tool that takes a minute unless cancelled; `finished` says whether its /// runner ever completed. fn slow_tool(finished: Arc) -> ToolDef { @@ -348,7 +401,10 @@ async fn main() -> Result<(), BoxError> { // Run 2: the tool added while the host ran. call("word_count", json!({"path": path})), MockResponse::Text("Counted.".into()), - // Run 3: cancelled mid-call. + // Run 3: a picture. + call("dot_picture", json!({})), + MockResponse::Text("Seen.".into()), + // Run 4: cancelled mid-call. call("slow", json!({})), MockResponse::Text("never sent".into()), ]); @@ -415,7 +471,19 @@ async fn main() -> Result<(), BoxError> { format!("word_count was not hot-added between the runs: {offered:?}"), )?; - // 3. Cancel: yoagent's token stops rutis-agent's runner. + // 3. Images: a runner's content blocks reach yoagent as an image. + registry.register(dot_picture_tool()); + let before = agent.messages().len(); + run(&mut agent, "Show me the dot.").await?; + let image = agent.messages()[before..].iter().any(|m| { + matches!(m, AgentMessage::Llm(Message::ToolResult { tool_name, content, .. }) + if tool_name == "dot_picture" + && content.iter().any(|c| matches!(c, Content::Image { data, .. } if data == DOT_PNG))) + }); + println!(" dot_picture returned an image: {image}"); + check(image, "dot_picture's image did not reach yoagent")?; + + // 4. Cancel: yoagent's token stops rutis-agent's runner. let finished = Arc::new(AtomicBool::new(false)); registry.register(slow_tool(finished.clone())); let mut events = agent.prompt("Take your time.").await; diff --git a/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts b/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts index efe4cfd..64571a1 100644 --- a/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts +++ b/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts @@ -20,14 +20,30 @@ // call_tool `tools.execute({callId, name, arguments, signal})`, with the // bridge's cancel handle as dsh's `signal`: cancelling the // yoagent run aborts the dsh call. `isError` → an error tool -// result; text blocks are joined, other blocks named. +// result. Text blocks stay text; an image block (a reference +// into dsh's attachment store) becomes a yoagent image, read +// with the `attachments` service when one is loaded (looked up +// per image, not injected: the adapter runs without it); with +// none, or when a read fails, the image is a text placeholder. +// Other blocks are named. // before_model the sections dsh plugins added to `systemPrompt` (the // harness identity and persona slots left out), rendered and // capped, as a note on the request's latest user turn. import { definePlugin } from '@arcships/rutis' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import type { Cancellable, ToolCall, ToolResult, ToolSpec, Yoagent } from '../yoagent.d.ts' +import type { Cancellable, ContentBlock, ToolCall, ToolResult, ToolSpec, Yoagent } from '../yoagent.d.ts' + +/** The slice of `@deepseek-ai/dsh-attachment`'s `AttachmentStore` this adapter uses. */ +interface DshAttachments { + readImage( + ref: { attachmentId: string; mediaType: string }, + signal?: AbortSignal, + ): Promise<{ ref: { mediaType: string }; data: Uint8Array }> +} + +/** A dsh content block as a tool result carries it. */ +type DshBlock = { type: string; text?: string; attachment?: { attachmentId: string; mediaType: string; name?: string } } /** The slice of `@deepseek-ai/dsh-tools`' `ToolRuntime` this adapter uses. */ interface DshTools { @@ -39,7 +55,7 @@ interface DshTools { signal: AbortSignal }): Promise<{ isError: boolean - content?: { type: string; text?: string }[] + content?: DshBlock[] error?: { message?: string; info?: { code?: string } } }> } @@ -87,8 +103,34 @@ export default definePlugin({ const prompt = ctx.use('systemPrompt') const yoagent = ctx.use('yoagent') const only = config?.tools ? new Set(config.tools) : undefined + /** dsh's attachment store, when one is loaded: optional, so looked up per use rather than injected. */ + const attachments = (): DshAttachments | undefined => { + try { + return ctx.use('attachments') + } catch { + return undefined + } + } const maxNote = config?.maxNoteChars ?? 2000 + /** An image reference as a yoagent image: its bytes from dsh's attachment store. */ + const image = async ( + ref: { attachmentId: string; mediaType: string; name?: string }, + signal: AbortSignal, + ): Promise => { + const label = `[image${ref.name ? ` ${ref.name}` : ''}: not available here]` + const store = attachments() + if (!store) return { type: 'text', text: label } + try { + const stored = await store.readImage(ref, signal) + return { type: 'image', data: Buffer.from(stored.data).toString('base64'), mimeType: stored.ref.mediaType } + } catch (error) { + if (signal.aborted) throw error + console.warn(`[dsh] image ${ref.attachmentId} could not be read: ${error}`) + return { type: 'text', text: label } + } + } + ctx.effect( yoagent.register(config?.name ?? 'dsh-tools', { async tools(): Promise { @@ -109,13 +151,21 @@ export default definePlugin({ arguments: call.args, signal: call.signal, }) - const text = (out.content ?? []) - .map((block) => (block.type === 'text' ? (block.text ?? '') : `[${block.type} block]`)) - .join('\n') + const content: ContentBlock[] = [] + for (const block of out.content ?? []) { + if (block.type === 'text') { + content.push({ type: 'text', text: block.text ?? '' }) + } else if (block.type === 'image' && block.attachment) { + content.push(await image(block.attachment, call.signal)) + } else { + content.push({ type: 'text', text: `[${block.type} block]` }) + } + } if (out.isError) { + const text = content.flatMap((b) => (b.type === 'text' ? [b.text] : [])).join('\n') return { text: text || out.error?.message || 'the dsh tool failed', is_error: true } } - return { text } + return { content } }, async before_model(turn: Cancellable) { diff --git a/integrations/yoagent-rutis/plugins/dsh/fixture-attachments.ts b/integrations/yoagent-rutis/plugins/dsh/fixture-attachments.ts new file mode 100644 index 0000000..8508b10 --- /dev/null +++ b/integrations/yoagent-rutis/plugins/dsh/fixture-attachments.ts @@ -0,0 +1,22 @@ +// A test fixture: a stand-in for dsh's `attachments` service +// (`@deepseek-ai/dsh-attachment`'s `AttachmentStore`), holding one image — +// a 1×1 PNG under the id `fixture-dot`. Only `readImage` is implemented. +// Not for production use. + +import { definePlugin } from '@arcships/rutis' + +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==', + 'base64', +) + +export default definePlugin({ + apply(ctx) { + ctx.provide('attachments', { + async readImage(ref: { attachmentId: string }) { + if (ref.attachmentId !== 'fixture-dot') throw new Error(`no attachment ${ref.attachmentId}`) + return { ref, data: new Uint8Array(PNG) } + }, + }) + }, +}) diff --git a/integrations/yoagent-rutis/plugins/dsh/fixture-tools.ts b/integrations/yoagent-rutis/plugins/dsh/fixture-tools.ts index 60be07c..fb5323e 100644 --- a/integrations/yoagent-rutis/plugins/dsh/fixture-tools.ts +++ b/integrations/yoagent-rutis/plugins/dsh/fixture-tools.ts @@ -5,6 +5,8 @@ // // fixture_echo {text} → "echo: " // fixture_fail {why} → throws: dsh reports an `isError` result +// fixture_dot {} → a text block and an image block referring to the +// attachment `fixture-dot` (see fixture-attachments.ts) // fixture_slow {} → writes "started" to `config.abortFile`, waits for // `exec.signal` to abort (60 s at most), then // writes how it ended there @@ -49,6 +51,24 @@ export function apply(ctx: any, config: Config | undefined) { throw new Error(`fixture failure: ${args.why}`) }, }), + defineTool({ + name: 'fixture_dot', + description: 'Shows a picture of a dot.', + parameters: {}, + output: { + schema: TEXT, + render: () => [ + { type: 'text' as const, text: 'a dot' }, + { + type: 'image' as const, + attachment: { attachmentId: 'fixture-dot', mediaType: 'image/png', bytes: 70, width: 1, height: 1 }, + }, + ], + }, + async execute() { + return 'a dot' + }, + }), defineTool({ name: 'fixture_slow', description: 'Waits until it is cancelled.', diff --git a/integrations/yoagent-rutis/plugins/yoagent.d.ts b/integrations/yoagent-rutis/plugins/yoagent.d.ts index a2c0feb..4fb20bb 100644 --- a/integrations/yoagent-rutis/plugins/yoagent.d.ts +++ b/integrations/yoagent-rutis/plugins/yoagent.d.ts @@ -152,17 +152,30 @@ export interface ToolCall extends RunInfo { /** Allow (nothing), deny with a reason the model sees, or rewrite the arguments. */ export type ToolVerdict = void | null | { deny: string } | { args: Record } +/** + * A content block, in yoagent's JSON shape (also pi's and MCP's): text, or + * an image as base64 `data` with an `image/*` `mimeType`. + */ +export type ContentBlock = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string } + export interface ToolOutput { /** The text blocks, joined. */ text: string /** yoagent's content blocks (text and images). */ - content: unknown[] + content: ContentBlock[] details: unknown is_error: boolean } -/** Keep the output (nothing), or replace parts of it: `text` replaces every content block with one text block. */ -export type OutputEdit = void | null | { text?: string; details?: unknown; is_error?: boolean } +/** + * Keep the output (nothing), or replace parts of it: `text` replaces every + * content block with one text block, `content` with the given blocks (so + * images can be kept, added or dropped). Not both. + */ +export type OutputEdit = + | void + | null + | { text?: string; content?: ContentBlock[]; details?: unknown; is_error?: boolean } export interface Turn extends RunInfo { model: string @@ -213,9 +226,13 @@ export interface ToolSpec { /** * The tool's text, or the text plus details (a missing `text` — `{}` too — - * is an empty text); `is_error` (or a throw) fails the call. + * is an empty text), or `content` blocks instead of `text` (images included; + * not both); `is_error` (or a throw) fails the call, with the text blocks as + * its message. */ -export type ToolResult = string | { text?: string; details?: unknown; is_error?: boolean } +export type ToolResult = + | string + | { text?: string; content?: ContentBlock[]; details?: unknown; is_error?: boolean } export type AgentEventType = | 'agentStart' diff --git a/integrations/yoagent-rutis/src/languages.rs b/integrations/yoagent-rutis/src/languages.rs index 4687853..daba871 100644 --- a/integrations/yoagent-rutis/src/languages.rs +++ b/integrations/yoagent-rutis/src/languages.rs @@ -512,7 +512,8 @@ impl HandlerImpl for RemoteHandler { }; if edit .keys() - .any(|k| !["text", "details", "is_error"].contains(&k.as_str())) + .any(|k| !["text", "content", "details", "is_error"].contains(&k.as_str())) + || (edit.contains_key("text") && edit.contains_key("content")) { return Err(unexpected("after_tool", &value)); } @@ -524,6 +525,10 @@ impl HandlerImpl for RemoteHandler { text: text.to_string(), }]; } + if let Some(content) = edit.get("content") { + output.result.content = + content_blocks(content).ok_or_else(|| unexpected("after_tool", &value))?; + } if let Some(details) = edit.get("details") { output.result.details = details.clone(); } @@ -895,18 +900,26 @@ impl AgentTool for RemoteTool { .map_err(|e| ToolError::Failed(e.to_string()))?; let unexpected = || ToolError::Failed(format!("`call_tool` returned an unexpected value: {value}")); - let (text, details, is_error) = match &value { - Json::String(text) => (text.clone(), Json::Null, false), + let (content, details, is_error) = match &value { + Json::String(text) => ( + vec![Content::Text { text: text.clone() }], + Json::Null, + false, + ), // `{}` is an empty text, as `after_tool`'s edit reads it. Json::Object(fields) if fields .keys() - .all(|k| ["text", "details", "is_error"].contains(&k.as_str())) => + .all(|k| ["text", "content", "details", "is_error"].contains(&k.as_str())) + && !(fields.contains_key("text") && fields.contains_key("content")) => { - let text = match fields.get("text") { - None => String::new(), - Some(Json::String(text)) => text.clone(), - Some(_) => return Err(unexpected()), + let content = match (fields.get("text"), fields.get("content")) { + (Some(Json::String(text)), _) => vec![Content::Text { text: text.clone() }], + (Some(_), _) => return Err(unexpected()), + (None, Some(blocks)) => content_blocks(blocks).ok_or_else(unexpected)?, + (None, None) => vec![Content::Text { + text: String::new(), + }], }; let is_error = match fields.get("is_error") { None => false, @@ -914,20 +927,59 @@ impl AgentTool for RemoteTool { Some(_) => return Err(unexpected()), }; let details = fields.get("details").cloned().unwrap_or(Json::Null); - (text, details, is_error) + (content, details, is_error) } _ => return Err(unexpected()), }; if is_error { + let text = content + .iter() + .filter_map(|c| match c { + Content::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); return Err(ToolError::Failed(text)); } - Ok(ToolResult { - content: vec![Content::Text { text }], - details, - }) + Ok(ToolResult { content, details }) } } +/// A handler's content blocks — `{"type": "text", "text"}` and +/// `{"type": "image", "data", "mimeType"}`, yoagent's own JSON shape (and +/// pi's, and MCP's) — as yoagent content. `None` for anything else: not an +/// array, an unknown block type, an extra or missing field, an image whose +/// `mimeType` is not `image/*` or whose `data` is empty or not base64. +fn content_blocks(blocks: &Json) -> Option> { + blocks + .as_array()? + .iter() + .map(|block| { + let fields = block.as_object()?; + let field = |name: &str| fields.get(name)?.as_str(); + match field("type")? { + "text" if fields.len() == 2 => Some(Content::Text { + text: field("text")?.to_string(), + }), + "image" if fields.len() == 3 => { + let data = field("data")?; + let mime_type = field("mimeType")?; + let base64 = !data.is_empty() + && data + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=')); + (base64 && mime_type.starts_with("image/")).then(|| Content::Image { + data: data.to_string(), + mime_type: mime_type.to_string(), + }) + } + _ => None, + } + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -937,6 +989,32 @@ mod tests { type Failed = Arc>>; + #[test] + fn content_blocks_take_text_and_images_in_yoagents_shape() { + let blocks = content_blocks(&json!([ + {"type": "text", "text": "see"}, + {"type": "image", "data": "iVBORw0KGgo=", "mimeType": "image/png"}, + ])) + .unwrap(); + assert!(matches!(&blocks[0], Content::Text { text } if text == "see")); + assert!(matches!(&blocks[1], Content::Image { data, mime_type } + if data == "iVBORw0KGgo=" && mime_type == "image/png")); + assert!(content_blocks(&json!([])).unwrap().is_empty()); + for bad in [ + json!("text"), + json!([{"type": "text"}]), + json!([{"type": "text", "text": 1}]), + json!([{"type": "text", "text": "a", "extra": 1}]), + json!([{"type": "image", "data": "", "mimeType": "image/png"}]), + json!([{"type": "image", "data": "not base64!", "mimeType": "image/png"}]), + json!([{"type": "image", "data": "AAAA", "mimeType": "text/plain"}]), + json!([{"type": "image", "data": "AAAA"}]), + json!([{"type": "file", "data": "AAAA", "mimeType": "image/png"}]), + ] { + assert!(content_blocks(&bad).is_none(), "{bad}"); + } + } + /// How the test handler's event delivery goes wrong. #[derive(Clone, Copy)] enum Breaks { diff --git a/integrations/yoagent-rutis/tests/dsh_test.rs b/integrations/yoagent-rutis/tests/dsh_test.rs index 51b676f..5c27fad 100644 --- a/integrations/yoagent-rutis/tests/dsh_test.rs +++ b/integrations/yoagent-rutis/tests/dsh_test.rs @@ -25,6 +25,7 @@ use rutis_loader::{ }; use serde_json::{json, Value}; use yoagent::provider::mock::{MockResponse, MockToolCall}; +use yoagent::{AgentMessage, Content, Message}; use yoagent_rutis::RutisBridge; fn dsh_dir() -> PathBuf { @@ -234,7 +235,62 @@ async fn dsh_tools_reach_a_yoagent_agent_through_the_adapter() { .await .expect("the cancelled run ends"); agent.finish().await; - let ended = wait_file(&abort_file, |t| t != "started").await; + // Not empty: writeFileSync truncates before it writes. + let ended = wait_file(&abort_file, |t| t != "started" && !t.is_empty()).await; assert_eq!(ended, "aborted: AbortError"); host.root.shutdown().await.unwrap(); } + +#[tokio::test(flavor = "multi_thread")] +async fn dsh_images_arrive_as_images_when_an_attachment_store_is_loaded() { + let Some(runtime) = node_runtime() else { + return; + }; + for with_store in [true, false] { + let host = host(&runtime).await; + let mut rows = vec![ + json!({ "id": "system-prompt", "name": "@deepseek-ai/dsh-system-prompt" }), + json!({ "id": "tools", "name": "@deepseek-ai/dsh-tools" }), + json!({ "id": "fixture", "name": dsh_dir().join("fixture-tools.ts") }), + json!({ "id": "adapter", "name": dsh_dir().join("dsh-tools-adapter.ts") }), + ]; + if with_store { + // Loaded after the adapter: it is looked up when an image is read. + rows.push( + json!({ "id": "attachments", "name": dsh_dir().join("fixture-attachments.ts") }), + ); + } + host.load(Value::Array(rows)).await; + let (agent, _) = agent(vec![call("fixture_dot", json!({})), text("done")]); + let mut agent = agent.with_extension(host.bridge.extension()); + run(&mut agent, "go").await; + let content = agent + .messages() + .iter() + .find_map(|m| match m { + AgentMessage::Llm(Message::ToolResult { + tool_name, content, .. + }) if tool_name == "fixture_dot" => Some(content.clone()), + _ => None, + }) + .expect("fixture_dot ran"); + assert!( + matches!(&content[0], Content::Text { text } if text == "a dot"), + "{content:?}" + ); + if with_store { + // The attachment's bytes, read from the store, as a yoagent image. + assert!( + matches!(&content[1], Content::Image { data, mime_type } + if mime_type == "image/png" && data.starts_with("iVBORw0KGgo")), + "{content:?}" + ); + } else { + assert!( + matches!(&content[1], Content::Text { text } if text.contains("not available")), + "{content:?}" + ); + } + host.root.shutdown().await.unwrap(); + } +} diff --git a/integrations/yoagent-rutis/tests/languages_test.rs b/integrations/yoagent-rutis/tests/languages_test.rs index b392bc6..ea579bc 100644 --- a/integrations/yoagent-rutis/tests/languages_test.rs +++ b/integrations/yoagent-rutis/tests/languages_test.rs @@ -30,7 +30,9 @@ use rutis_loader::{ use serde_json::{json, Value}; use yoagent::extension::RunContext; use yoagent::provider::mock::{MockResponse, MockToolCall}; -use yoagent::{AgentEvent, Extension, ToolCallRequest, ToolDecision}; +use yoagent::{ + AgentEvent, AgentMessage, Content, Extension, Message, ToolCallRequest, ToolDecision, +}; use yoagent_rutis::RutisBridge; fn plugins_dir() -> PathBuf { @@ -414,6 +416,54 @@ export default definePlugin({ }) "#; +/// A 1×1 PNG, base64. +const PNG_B64: &str = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=="; + +/// Image tool results, both ways: a tool returning text and an image, and an +/// `after_tool` edit replacing the content with blocks (images kept). +const JS_IMAGES: &str = r#" +import { definePlugin } from 'RUTIS' +export default definePlugin({ + inject: ['yoagent'], + apply(ctx) { + const yoagent = ctx.use('yoagent') + ctx.effect(yoagent.register('js-images', { + async tools() { return [{ name: 'js_image', description: 'returns a picture' }] }, + async call_tool() { + return { content: [{ type: 'text', text: 'a dot' }, { type: 'image', data: 'PNG_B64', mimeType: 'image/png' }] } + }, + async after_tool(call, output) { + // Annotates the result, keeping its blocks (the image included). + if (call.tool === 'js_image') return { content: [...output.content, { type: 'text', text: 'checked by js' }] } + }, + })) + }, +}) +"#; + +const PY_IMAGES: &str = r#" +inject = ["yoagent"] + + +def apply(ctx, config): + yoagent = ctx.use("yoagent") + + async def tools(run): + return [{"name": "py_image", "description": "returns a picture"}] + + async def call_tool(call): + return {"content": [{"type": "text", "text": "a dot"}, {"type": "image", "data": "PNG_B64", "mimeType": "image/png"}], "details": {"px": 1}} + + async def after_tool(call, output): + # Drops the text, keeps only the images. + if call["tool"] == "py_image": + return {"content": [b for b in output["content"] if b["type"] == "image"]} + return None + + ctx.effect(yoagent.register("py-images", {"tools": tools, "call_tool": call_tool, "after_tool": after_tool})) +"#; + /// A dict of functions, in Python, and a tool that kills its runtime. const PY_HOOKS: &str = r#" import os @@ -595,6 +645,7 @@ struct Fixtures { js: PathBuf, strict: PathBuf, cancel: PathBuf, + images: PathBuf, py: PathBuf, _dir: tempfile::TempDir, } @@ -612,14 +663,28 @@ fn fixtures() -> Fixtures { std::fs::write(&strict, JS_STRICT.replace("RUTIS", &rutis)).unwrap(); let cancel = dir.path().join("cancel.mjs"); std::fs::write(&cancel, JS_CANCEL.replace("RUTIS", &rutis)).unwrap(); + let images = dir.path().join("images.mjs"); + std::fs::write( + &images, + JS_IMAGES + .replace("RUTIS", &rutis) + .replace("PNG_B64", PNG_B64), + ) + .unwrap(); let py = dir.path().join("py"); std::fs::create_dir_all(&py).unwrap(); std::fs::write(py.join("py_hooks.py"), PY_HOOKS).unwrap(); std::fs::write(py.join("py_cancel.py"), PY_CANCEL).unwrap(); + std::fs::write( + py.join("py_images.py"), + PY_IMAGES.replace("PNG_B64", PNG_B64), + ) + .unwrap(); Fixtures { js, strict, cancel, + images, py, _dir: dir, } @@ -1243,3 +1308,54 @@ async fn a_python_hook_sees_its_signal_set_when_the_run_is_cancelled() { ); host.root.shutdown().await.unwrap(); } + +#[tokio::test(flavor = "multi_thread")] +async fn image_tool_results_cross_both_ways_in_typescript_and_python() { + let (Some(node), Some(python)) = (node_runtime(), python()) else { + return; + }; + let fx = fixtures(); + let host = host(Some(&node), Some((&fx.py, &python))).await; + host.load(vec![ + ts_row("images", &fx.images, json!({})), + py_row("py-images", "py_images", json!({})), + ]) + .await; + host.until_handlers(&["js-images", "py-images"]).await; + + let (agent, _) = agent(vec![ + call("js_image", json!({})), + call("py_image", json!({})), + text("done"), + ]); + let mut agent = agent.with_extension(host.bridge.extension()); + run(&mut agent, "go").await; + let results: Vec<(String, Vec)> = agent + .messages() + .iter() + .filter_map(|m| match m { + AgentMessage::Llm(Message::ToolResult { + tool_name, content, .. + }) => Some((tool_name.clone(), content.clone())), + _ => None, + }) + .collect(); + let image = |c: &Content| matches!(c, Content::Image { data, mime_type } if data == PNG_B64 && mime_type == "image/png"); + // js_image: text + image from call_tool, and JS's edit appended a note + // keeping both. + let (_, js) = &results[0]; + assert_eq!(js.len(), 3, "{js:?}"); + assert!( + matches!(&js[0], Content::Text { text } if text == "a dot"), + "{js:?}" + ); + assert!(image(&js[1]), "{js:?}"); + assert!( + matches!(&js[2], Content::Text { text } if text == "checked by js"), + "{js:?}" + ); + // py_image: text + image from call_tool, and Python's edit kept only the image. + let (_, py) = &results[1]; + assert!(py.len() == 1 && image(&py[0]), "{py:?}"); + host.root.shutdown().await.unwrap(); +} From c85a886baf04b9725b3dd42f2414ab70de1a0ec5 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 21:24:19 +0200 Subject: [PATCH 09/15] feat(rutis): pi adapter passes images through pi's text and image blocks are yoagent's shape: call_tool returns them as content blocks, and a tool_result content edit is sent back as blocks, so pictures survive. Fixture pi_picture + caption edit; test. pi_test no longer reads the slow tool's file mid-write. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- integrations/yoagent-rutis/README.md | 4 +-- .../plugins/pi/fixture-extension.ts | 26 ++++++++++++++++ .../plugins/pi/pi-extensions-adapter.ts | 31 ++++++++++++++----- integrations/yoagent-rutis/tests/pi_test.rs | 20 +++++++++++- 4 files changed, 70 insertions(+), 11 deletions(-) diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 9c782cd..9826077 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -343,10 +343,10 @@ onto one handler, following pi 1.1.0's own runner and agent loop: | pi | yoagent | |---|---| -| `pi.registerTool` | a tool, while pi would activate it (exposure `direct` / `model-only`, `defaultActive` not false, in the `setActiveTools` allowlist if one was set; the first registration of a name wins). Arguments go through `prepareArguments` and pi's validation before any policy sees them, and `execute` runs only with the arguments the policies left — a call another handler rewrote afterwards is not run. A throw or `isError` is an error result (non-text blocks become `[image block]` text) | +| `pi.registerTool` | a tool, while pi would activate it (exposure `direct` / `model-only`, `defaultActive` not false, in the `setActiveTools` allowlist if one was set; the first registration of a name wins). Arguments go through `prepareArguments` and pi's validation before any policy sees them, and `execute` runs only with the arguments the policies left — a call another handler rewrote afterwards is not run. A throw or `isError` is an error result; text and image blocks pass through (pi's shape is yoagent's) | | `pi.setActiveTools` | an allowlist over the tools pi knows — yoagent's built-ins under pi's names and the extensions' tools: those outside it are not offered and their calls are denied. Tools pi does not know (MCP tools, sub-agents, the host's own) are not affected, as in pi. As in pi, a tool named in it is activated even when `defaultActive` is false (not when hidden), and a tool registered later that pi would activate joins it. `getActiveTools` / `getAllTools` answer from the same view | | `on("tool_call")` | `before_tool` for every call. yoagent's built-ins are judged under pi's names (`bash`, `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit` with `edits`, `search`→`grep` with `glob` / `ignoreCase` (yoagent's search is case-insensitive unless asked, pi's grep the reverse), `list_files`→`find`; config `toolNames` adds more), with a relative `path` resolved against `cwd` first so the tool acts where the policy looked (yoagent's built-ins only: another tool's `path` may be a repository's). `{ block }` denies (`terminate: true` also stops the run at its next model request — stricter than pi, which ends only a batch whose results all set it); in-place changes to `event.input` rewrite the arguments, and one the yoagent tool cannot take (a second edit, a `timeout` on `bash`) denies the call; a throwing handler blocks, as in pi | -| `on("tool_result")` | `after_tool`: content, details and isError edits, chained; only the fields a handler set are applied (a replaced content keeps only its text). A throwing handler **withholds the result** — unlike pi, which skips it: a failed redaction must not let the raw output through | +| `on("tool_result")` | `after_tool`: content, details and isError edits, chained; only the fields a handler set are applied (a replaced content keeps its text and image blocks). A throwing handler **withholds the result** — unlike pi, which skips it: a failed redaction must not let the raw output through | | `on("input")` | `on_input`: `handled` rejects the prompt (it never reaches the agent, as in pi); `transform` and a throwing handler reject it too (yoagent cannot rewrite a prompt) | | `on("before_agent_start")` | `before_model`: the handlers run once per run, and the text they add around `event.systemPrompt` is a note on the latest user turn of every request of that run. A handler that throws, replaces the prompt or changes `systemPromptOptions` is skipped with a warning — even under `strict` (a run cannot refuse the load), so a policy that replaces the prompt (a "read-only mode") does not apply; the others still count. A returned `message` is dropped, the same handler's addition kept | | `on("session_start")` / `on("session_shutdown")` | when the adapter loads / unloads; a `session_start` handler that throws refuses the load (its extension may be missing its policies) | diff --git a/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts b/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts index bb5d22f..06eb477 100644 --- a/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts +++ b/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts @@ -49,6 +49,27 @@ export default function (pi: ExtensionAPI) { }, }) + // A picture: text and an image block, pi's shape. + pi.registerTool({ + name: 'pi_picture', + label: 'Picture', + description: 'Shows a picture of a dot.', + parameters: Type.Object({}), + async execute() { + return { + content: [ + { type: 'text', text: 'a dot' }, + { + type: 'image', + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==', + mimeType: 'image/png', + }, + ], + details: undefined, + } + }, + }) + // A tool registered when the session starts (pi's dynamic-tools pattern). pi.on('session_start', () => { pi.registerTool({ @@ -80,6 +101,11 @@ export default function (pi: ExtensionAPI) { return undefined }) + // A content edit that keeps the picture and adds a caption. + pi.on('tool_result', async (event) => + event.toolName === 'pi_picture' ? { content: [...event.content, { type: 'text', text: 'captioned' }] } : undefined, + ) + // Redaction: results are chained edits. pi.on('tool_result', async (event) => { const text = event.content.map((block) => (block.type === 'text' ? block.text : '')).join('') diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts index 1e65a0b..2a5fb59 100644 --- a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -22,8 +22,9 @@ // before any policy sees them; `execute` gets the // arguments the policies left, and only those: a // call another handler rewrote afterwards is not -// run. A throw or `isError` is an error result -// (non-text blocks become `[image block]` text). +// run. A throw or `isError` is an error result; +// text and image blocks pass through as they are +// (pi's shape is yoagent's). // A tool that overrides one of pi's built-ins // (`read`, `edit`, ...) makes the adapter deny // yoagent's counterpart (`read_file`, ...), so the @@ -54,8 +55,8 @@ // handler that throws blocks, as in pi. // on("tool_result") `after_tool`: changes to content, details and // isError are chained; only the fields a handler -// set are applied (a replaced content keeps only its -// text). A handler that throws withholds the result +// set are applied (a replaced content keeps its text +// and image blocks). A handler that throws withholds the result // — unlike pi, which skips it: a redaction that // failed must not let the raw output through. // on("input") `on_input`: `handled` rejects the prompt (it never @@ -93,7 +94,7 @@ import { isAbsolute, resolve } from 'node:path' import { definePlugin } from '@arcships/rutis' -import type { Cancellable, ToolCall, ToolOutput, ToolResult, ToolSpec, Yoagent } from '../yoagent.d.ts' +import type { Cancellable, ContentBlock, ToolCall, ToolOutput, ToolResult, ToolSpec, Yoagent } from '../yoagent.d.ts' export interface Config { /** The handler's name in the bridge (unique across the host's plugins). */ @@ -240,6 +241,8 @@ const NO_UI = new Proxy( interface Block { type: string text?: string + data?: string + mimeType?: string } interface PiTool { @@ -300,6 +303,17 @@ function text(content: Block[] | undefined): string { return (content ?? []).map((block) => (block.type === 'text' ? (block.text ?? '') : `[${block.type} block]`)).join('\n') } +/** pi's content blocks as yoagent's (the same shape for text and images); any other block is named in text. */ +function blocks(content: Block[] | undefined): ContentBlock[] { + return (content ?? []).map((block): ContentBlock => { + if (block.type === 'text') return { type: 'text', text: block.text ?? '' } + if (block.type === 'image' && block.data && block.mimeType) { + return { type: 'image', data: block.data, mimeType: block.mimeType } + } + return { type: 'text', text: `[${block.type} block]` } + }) +} + /** * JSON with object keys sorted and non-integers at 15 significant digits: * arguments cross serde_json, which may reorder keys and parse a float's @@ -642,7 +656,8 @@ export default definePlugin({ } try { const out = await tool.execute(call.call_id, call.args, call.signal, undefined, toolContext(call.signal)) - return { text: text(out.content), details: out.details ?? null, is_error: out.isError === true } + if (out.isError === true) return { text: text(out.content), details: out.details ?? null, is_error: true } + return { content: blocks(out.content), details: out.details ?? null } } catch (error) { return { text: message(error), is_error: true } } @@ -768,8 +783,8 @@ export default definePlugin({ } if (!changed.content && !changed.details && !changed.isError) return return { - // Only a replaced content is sent back, as text: yoagent's edit replaces every block. - ...(changed.content ? { text: text(event.content) } : {}), + // Only a replaced content is sent back, as blocks (images kept): yoagent's edit replaces every block. + ...(changed.content ? { content: blocks(event.content) } : {}), ...(changed.details ? { details: event.details ?? null } : {}), ...(changed.isError ? { is_error: event.isError } : {}), } diff --git a/integrations/yoagent-rutis/tests/pi_test.rs b/integrations/yoagent-rutis/tests/pi_test.rs index 282d579..57d1d44 100644 --- a/integrations/yoagent-rutis/tests/pi_test.rs +++ b/integrations/yoagent-rutis/tests/pi_test.rs @@ -201,6 +201,7 @@ async fn pi_extensions_reach_a_yoagent_agent_through_the_adapter() { ("pi_echo", json!({"text": "SECRET hi"})), ("pi_fail", json!({"why": "nope"})), ("pi_dynamic", json!({})), + ("pi_picture", json!({})), ( "write_file", json!({"path": env_file, "content": "TOKEN=1"}), @@ -255,6 +256,22 @@ async fn pi_extensions_reach_a_yoagent_agent_through_the_adapter() { let (_, text, is_error) = result("pi_fail"); assert!(is_error && text.contains("pi failure: nope"), "{results:?}"); assert_eq!(result("pi_dynamic").1, "dynamic ok"); + // ...a picture arrives as an image, and a content edit keeps it... + let picture = agent + .messages() + .iter() + .find_map(|m| match m { + AgentMessage::Llm(Message::ToolResult { + tool_name, content, .. + }) if tool_name == "pi_picture" => Some(content.clone()), + _ => None, + }) + .unwrap(); + assert!( + matches!(&picture[..], [Content::Text { text: a }, Content::Image { mime_type, .. }, Content::Text { text: b }] + if a == "a dot" && mime_type == "image/png" && b == "captioned"), + "{picture:?}" + ); // ...yoagent's own write_file is judged as pi's `write` and blocked... let (_, text, is_error) = result("write_file"); assert!(is_error && text.contains("is protected"), "{results:?}"); @@ -296,7 +313,8 @@ async fn pi_extensions_reach_a_yoagent_agent_through_the_adapter() { .await .expect("the cancelled run ends"); agent.finish().await; - let ended = wait_file(&slow, |t| t != "started").await; + // Not empty: writeFileSync truncates before it writes. + let ended = wait_file(&slow, |t| t != "started" && !t.is_empty()).await; assert_eq!(ended, "aborted: AbortError"); host.root.shutdown().await.unwrap(); // session_shutdown ran when the adapter unloaded. From d5a9dbf5b8389421175f15cf5207979225401ab0 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 21:27:42 +0200 Subject: [PATCH 10/15] =?UTF-8?q?fix(rutis):=20image=20review=20=E2=80=94?= =?UTF-8?q?=20strict=20base64,=20provider=20image=20types,=20size=20limits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - content_blocks decodes base64 (STANDARD), takes png/jpeg/gif/webp only, and refuses images over 10 MB (websocket frame limit): an image a provider refuses would sit in history and fail every later request - dsh adapter: dsh's offloaded images and images over 3.75 MB (under Anthropic's 5 MB base64) stay text; an error result's images are not read - rutis-agent example: blocks only with at least one image - docs: limits, picking fields in after_tool edits, text join Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CLAUDE.md | 2 +- integrations/yoagent-rutis/Cargo.toml | 2 + integrations/yoagent-rutis/README.md | 18 ++++---- .../examples/rutis-agent-tools/src/main.rs | 19 ++++----- .../plugins/dsh/dsh-tools-adapter.ts | 41 ++++++++++++++----- .../plugins/dsh/fixture-tools.ts | 16 +++++++- .../yoagent-rutis/plugins/yoagent.d.ts | 17 +++++--- integrations/yoagent-rutis/src/languages.rs | 40 ++++++++++++++---- integrations/yoagent-rutis/tests/dsh_test.rs | 9 ++++ 9 files changed, 118 insertions(+), 46 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 150cada..160fcbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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 (base64 charset, non-empty),mimeType (image/*)}`, nothing else) — 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.7.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), 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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; self-checks). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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]}` → 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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/` and `plugins/dsh/`, clippy per feature, docs, tests with the runtimes required, the `language_plugins` example; `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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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) — 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.7.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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; self-checks). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/` and `plugins/dsh/`, clippy per feature, docs, tests with the runtimes required, the `language_plugins` example; `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`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/integrations/yoagent-rutis/Cargo.toml b/integrations/yoagent-rutis/Cargo.toml index 4583c35..63e211f 100644 --- a/integrations/yoagent-rutis/Cargo.toml +++ b/integrations/yoagent-rutis/Cargo.toml @@ -34,6 +34,8 @@ rutis = "0.6" # rutis-bridge minor bump is a yoagent-rutis minor bump too. rutis-bridge = { version = "0.7", optional = true, default-features = false } async-trait = "0.1" +# Decodes plugin images to check them (yoagent itself uses 0.22). +base64 = "0.22" futures = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 4414fdd..18bb4cd 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -215,9 +215,12 @@ def apply(ctx, config): `{"type": "image", "data": , "mimeType": "image/…"}` — and `after_tool` sees the output's blocks as `output.content`, so a handler can return pictures, and keep, add or drop them when it edits a result. - Text and blocks are exclusive in one answer; anything else in `content` - (another block type, a non-`image/*` type, data that is not base64) fails - the answer. + Text and blocks are exclusive in one answer (so return picked fields, + not the `output` you were given). An image must be standard base64 of at + most 10 MB, typed `image/png`, `image/jpeg`, `image/gif` or `image/webp` + (what every provider takes); anything else in `content` fails the answer. + Stay under your provider's own limit too (Anthropic: 5 MB base64): an + image it refuses sits in the history and fails every later request. ```ts async call_tool(call) { @@ -280,7 +283,7 @@ offers every tool in dsh's tool registry (the `tools` service of | Hook | What the adapter does | |---|---| | `tools` | `tools.schemas()` → name, description, parameters (config `tools`: an allowlist) | -| `call_tool` | `tools.execute({callId, name, arguments, signal})` with the bridge's cancel handle as dsh's `signal`: cancelling the run aborts the dsh call. `isError` → an error tool result. Text blocks stay text; an image block — a reference into dsh's attachment store — becomes a yoagent image, its bytes read with the `attachments` service when one is loaded (looked up per image, so the adapter also runs without it; then, or when a read fails, the image is a text placeholder); other blocks are named | +| `call_tool` | `tools.execute({callId, name, arguments, signal})` with the bridge's cancel handle as dsh's `signal`: cancelling the run aborts the dsh call. `isError` → an error tool result. Text blocks stay text; an image block — a reference into dsh's attachment store — becomes a yoagent image, its bytes read with the `attachments` service when one is loaded (looked up per image, so the adapter also runs without it). Without one, when a read fails, when dsh marked the image `offloaded`, or over 3.75 MB (under Anthropic's 5 MB once base64), the image is a text placeholder; an error result's images are not read; other blocks are named | | `before_model` | the system-prompt sections dsh plugins added (the harness identity and persona slots left out, sections whose variables are unset skipped), as one note, capped at `maxNoteChars` (2000) | Load, as rows of one Node runtime whose `package.json` is `plugins/dsh/`'s: @@ -315,9 +318,10 @@ rutis-agent's), shown with rutis-agent's `replace_text` and a tool registered into the registry while the host runs, which the next run offers. rutis-agent's results are text (a runner's JSON value is serialized), so images use a convention of this adapter: a runner returning -`{"content": [blocks]}` in yoagent's block shape gives yoagent those blocks, -images included (`dot_picture` in the example); rutis-agent's own agent -still sees the JSON text. +`{"content": [blocks]}` in yoagent's block shape, with at least one image, +gives yoagent those blocks (`dot_picture` in the example); rutis-agent's +own agent still sees the JSON text. A tool that prints exactly such JSON +(an image included) as its text would be read as blocks too. ```sh cargo run --manifest-path examples/rutis-agent-tools/Cargo.toml [-- --live] diff --git a/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs b/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs index bf06112..baaf84b 100644 --- a/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs +++ b/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs @@ -179,8 +179,9 @@ impl AgentTool for RegistryTool { } /// A runner's `{"content": [blocks]}` value (as rutis-agent serialized it), -/// read back as yoagent content: only text and image blocks, and only when -/// the whole value is that shape — any other output stays text. +/// read back as yoagent content: only text and image blocks, only when the +/// whole value is that shape, and only with at least one image — so a tool +/// that prints such JSON as text (`cat result.json`) mostly stays text. fn content_of(output: &str) -> Option> { if !output.starts_with('{') { return None; @@ -190,16 +191,12 @@ fn content_of(output: &str) -> Option> { return None; } let blocks: Vec = serde_json::from_value(value.remove("content")?).ok()?; - blocks + let image = |b: &Content| matches!(b, Content::Image { data, mime_type } if !data.is_empty() && mime_type.starts_with("image/")); + let valid = blocks .iter() - .all(|b| match b { - Content::Text { .. } => true, - Content::Image { data, mime_type } => { - !data.is_empty() && mime_type.starts_with("image/") - } - _ => false, - }) - .then_some(blocks) + .all(|b| matches!(b, Content::Text { .. }) || image(b)) + && blocks.iter().any(image); + valid.then_some(blocks) } // ── The host ──────────────────────────────────────────────────── diff --git a/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts b/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts index 64571a1..294078e 100644 --- a/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts +++ b/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts @@ -23,9 +23,12 @@ // result. Text blocks stay text; an image block (a reference // into dsh's attachment store) becomes a yoagent image, read // with the `attachments` service when one is loaded (looked up -// per image, not injected: the adapter runs without it); with -// none, or when a read fails, the image is a text placeholder. -// Other blocks are named. +// per image, not injected: the adapter runs without it). With +// none, when a read fails, when dsh marked the image +// `offloaded`, or when it is over MAX_IMAGE_BYTES (provider-safe: +// Anthropic takes 5 MB base64), the image is a text +// placeholder. Other blocks are named. An error result's +// images are not read. // before_model the sections dsh plugins added to `systemPrompt` (the // harness identity and persona slots left out), rendered and // capped, as a note on the request's latest user turn. @@ -43,7 +46,15 @@ interface DshAttachments { } /** A dsh content block as a tool result carries it. */ -type DshBlock = { type: string; text?: string; attachment?: { attachmentId: string; mediaType: string; name?: string } } +type DshBlock = { + type: string + text?: string + attachment?: { attachmentId: string; mediaType: string; name?: string; bytes?: number } + offloaded?: true +} + +/** Largest image sent as an image: under Anthropic's 5 MB once base64-encoded. */ +const MAX_IMAGE_BYTES = 3_750_000 /** The slice of `@deepseek-ai/dsh-tools`' `ToolRuntime` this adapter uses. */ interface DshTools { @@ -115,14 +126,20 @@ export default definePlugin({ /** An image reference as a yoagent image: its bytes from dsh's attachment store. */ const image = async ( - ref: { attachmentId: string; mediaType: string; name?: string }, + ref: { attachmentId: string; mediaType: string; name?: string; bytes?: number }, + offloaded: boolean, signal: AbortSignal, ): Promise => { - const label = `[image${ref.name ? ` ${ref.name}` : ''}: not available here]` + const named = `image${ref.name ? ` ${ref.name}` : ''}` + const label = `[${named}: not available here]` + // dsh decided this image goes out as text; and an oversize one would fail the request. + if (offloaded) return { type: 'text', text: `[${named}: offloaded]` } + if ((ref.bytes ?? 0) > MAX_IMAGE_BYTES) return { type: 'text', text: `[${named}: too large to send]` } const store = attachments() if (!store) return { type: 'text', text: label } try { const stored = await store.readImage(ref, signal) + if (stored.data.byteLength > MAX_IMAGE_BYTES) return { type: 'text', text: `[${named}: too large to send]` } return { type: 'image', data: Buffer.from(stored.data).toString('base64'), mimeType: stored.ref.mediaType } } catch (error) { if (signal.aborted) throw error @@ -151,20 +168,22 @@ export default definePlugin({ arguments: call.args, signal: call.signal, }) + if (out.isError) { + const text = (out.content ?? []) + .map((block) => (block.type === 'text' ? (block.text ?? '') : `[${block.type} block]`)) + .join('\n') + return { text: text || out.error?.message || 'the dsh tool failed', is_error: true } + } const content: ContentBlock[] = [] for (const block of out.content ?? []) { if (block.type === 'text') { content.push({ type: 'text', text: block.text ?? '' }) } else if (block.type === 'image' && block.attachment) { - content.push(await image(block.attachment, call.signal)) + content.push(await image(block.attachment, block.offloaded === true, call.signal)) } else { content.push({ type: 'text', text: `[${block.type} block]` }) } } - if (out.isError) { - const text = content.flatMap((b) => (b.type === 'text' ? [b.text] : [])).join('\n') - return { text: text || out.error?.message || 'the dsh tool failed', is_error: true } - } return { content } }, diff --git a/integrations/yoagent-rutis/plugins/dsh/fixture-tools.ts b/integrations/yoagent-rutis/plugins/dsh/fixture-tools.ts index fb5323e..7dfe039 100644 --- a/integrations/yoagent-rutis/plugins/dsh/fixture-tools.ts +++ b/integrations/yoagent-rutis/plugins/dsh/fixture-tools.ts @@ -5,8 +5,9 @@ // // fixture_echo {text} → "echo: " // fixture_fail {why} → throws: dsh reports an `isError` result -// fixture_dot {} → a text block and an image block referring to the -// attachment `fixture-dot` (see fixture-attachments.ts) +// fixture_dot {} → a text block, an image block referring to the +// attachment `fixture-dot` (see fixture-attachments.ts), +// an offloaded one and an oversize one // fixture_slow {} → writes "started" to `config.abortFile`, waits for // `exec.signal` to abort (60 s at most), then // writes how it ended there @@ -63,6 +64,17 @@ export function apply(ctx: any, config: Config | undefined) { type: 'image' as const, attachment: { attachmentId: 'fixture-dot', mediaType: 'image/png', bytes: 70, width: 1, height: 1 }, }, + // dsh decided to send this one as text. + { + type: 'image' as const, + attachment: { attachmentId: 'fixture-dot', mediaType: 'image/png', bytes: 70, width: 1, height: 1, name: 'offloaded.png' }, + offloaded: true as const, + }, + // Over the adapter's provider-safe limit. + { + type: 'image' as const, + attachment: { attachmentId: 'fixture-huge', mediaType: 'image/png', bytes: 9_000_000, width: 9000, height: 9000, name: 'huge.png' }, + }, ], }, async execute() { diff --git a/integrations/yoagent-rutis/plugins/yoagent.d.ts b/integrations/yoagent-rutis/plugins/yoagent.d.ts index 4fb20bb..8816dcc 100644 --- a/integrations/yoagent-rutis/plugins/yoagent.d.ts +++ b/integrations/yoagent-rutis/plugins/yoagent.d.ts @@ -153,13 +153,17 @@ export interface ToolCall extends RunInfo { export type ToolVerdict = void | null | { deny: string } | { args: Record } /** - * A content block, in yoagent's JSON shape (also pi's and MCP's): text, or - * an image as base64 `data` with an `image/*` `mimeType`. + * A content block, in yoagent's JSON shape (also pi's, and MCP's bare + * blocks): text, or an image as standard base64 `data` (at most 10 MB + * decoded) with a `mimeType` of `image/png`, `image/jpeg`, `image/gif` or + * `image/webp` — the types every provider takes. Stay under your provider's + * own limit too (Anthropic: 5 MB base64): an image it refuses is in the + * history, and fails every later request. */ export type ContentBlock = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string } export interface ToolOutput { - /** The text blocks, joined. */ + /** The text blocks, concatenated with no separator. */ text: string /** yoagent's content blocks (text and images). */ content: ContentBlock[] @@ -170,7 +174,8 @@ export interface ToolOutput { /** * Keep the output (nothing), or replace parts of it: `text` replaces every * content block with one text block, `content` with the given blocks (so - * images can be kept, added or dropped). Not both. + * images can be kept, added or dropped). Not both — so do not return the + * `output` you were given (`{...output, text}` has both): pick the fields. */ export type OutputEdit = | void @@ -227,8 +232,8 @@ export interface ToolSpec { /** * The tool's text, or the text plus details (a missing `text` — `{}` too — * is an empty text), or `content` blocks instead of `text` (images included; - * not both); `is_error` (or a throw) fails the call, with the text blocks as - * its message. + * not both); `is_error` (or a throw) fails the call, with the text blocks, + * joined by newlines, as its message. */ export type ToolResult = | string diff --git a/integrations/yoagent-rutis/src/languages.rs b/integrations/yoagent-rutis/src/languages.rs index daba871..c590035 100644 --- a/integrations/yoagent-rutis/src/languages.rs +++ b/integrations/yoagent-rutis/src/languages.rs @@ -946,12 +946,23 @@ impl AgentTool for RemoteTool { } } +/// Image types every provider takes; others (svg, bmp, tiff, ...) are refused +/// rather than put into history, where they would fail every later request. +const IMAGE_TYPES: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"]; + +/// Largest image a plugin may hand over, decoded. Keeps one answer under the +/// websocket transport's frame limit (16 MiB) once base64-encoded; providers +/// may take less (Anthropic: 5 MB) — plugins should stay under theirs. +const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; + /// A handler's content blocks — `{"type": "text", "text"}` and /// `{"type": "image", "data", "mimeType"}`, yoagent's own JSON shape (and -/// pi's, and MCP's) — as yoagent content. `None` for anything else: not an -/// array, an unknown block type, an extra or missing field, an image whose -/// `mimeType` is not `image/*` or whose `data` is empty or not base64. +/// pi's, and MCP's bare blocks) — as yoagent content. `None` for anything +/// else: not an array, an unknown block type, an extra or missing field, an +/// image whose `mimeType` is not one of IMAGE_TYPES or whose `data` is not +/// standard base64 of 1 to MAX_IMAGE_BYTES bytes. fn content_blocks(blocks: &Json) -> Option> { + use base64::Engine as _; blocks .as_array()? .iter() @@ -965,11 +976,15 @@ fn content_blocks(blocks: &Json) -> Option> { "image" if fields.len() == 3 => { let data = field("data")?; let mime_type = field("mimeType")?; - let base64 = !data.is_empty() - && data - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=')); - (base64 && mime_type.starts_with("image/")).then(|| Content::Image { + // A decoded length bound before decoding: 3 bytes per 4 characters. + let fits = data.len() / 4 * 3 <= MAX_IMAGE_BYTES + 2; + let bytes = fits + .then(|| base64::engine::general_purpose::STANDARD.decode(data).ok()) + .flatten()?; + let valid = !bytes.is_empty() + && bytes.len() <= MAX_IMAGE_BYTES + && IMAGE_TYPES.contains(&mime_type); + valid.then(|| Content::Image { data: data.to_string(), mime_type: mime_type.to_string(), }) @@ -1008,6 +1023,15 @@ mod tests { json!([{"type": "image", "data": "", "mimeType": "image/png"}]), json!([{"type": "image", "data": "not base64!", "mimeType": "image/png"}]), json!([{"type": "image", "data": "AAAA", "mimeType": "text/plain"}]), + // Mis-padded, or padding in the middle: not base64. + json!([{"type": "image", "data": "AAAAA", "mimeType": "image/png"}]), + json!([{"type": "image", "data": "A=AA", "mimeType": "image/png"}]), + json!([{"type": "image", "data": "=", "mimeType": "image/png"}]), + // A type providers do not take. + json!([{"type": "image", "data": "AAAA", "mimeType": "image/svg+xml"}]), + json!([{"type": "image", "data": "AAAA", "mimeType": "image/"}]), + // Over the size limit. + json!([{"type": "image", "data": "A".repeat((MAX_IMAGE_BYTES + 3) / 3 * 4), "mimeType": "image/png"}]), json!([{"type": "image", "data": "AAAA"}]), json!([{"type": "file", "data": "AAAA", "mimeType": "image/png"}]), ] { diff --git a/integrations/yoagent-rutis/tests/dsh_test.rs b/integrations/yoagent-rutis/tests/dsh_test.rs index 5c27fad..0ded5bd 100644 --- a/integrations/yoagent-rutis/tests/dsh_test.rs +++ b/integrations/yoagent-rutis/tests/dsh_test.rs @@ -278,6 +278,15 @@ async fn dsh_images_arrive_as_images_when_an_attachment_store_is_loaded() { matches!(&content[0], Content::Text { text } if text == "a dot"), "{content:?}" ); + // dsh's offloaded image and the oversize one stay text, store or not. + assert!( + matches!(&content[2], Content::Text { text } if text.contains("offloaded.png: offloaded")), + "{content:?}" + ); + assert!( + matches!(&content[3], Content::Text { text } if text.contains("huge.png: too large")), + "{content:?}" + ); if with_store { // The attachment's bytes, read from the store, as a yoagent image. assert!( From 49cb4ef82c5ed39b35bc4002f63bbfba96e26200 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 21:31:12 +0200 Subject: [PATCH 11/15] fix(rutis): an after_tool edit may keep an image yoagent let in An image identical to one in the output the edit was given passes as it is: yoagent's read_file takes bmp up to 20 MB, MCP tools other types, and keeping them must not withhold the result. New or changed images are checked as before. Tests: kept bmp passes, changed data does not; the size pre-check's own rejection. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CLAUDE.md | 2 +- integrations/yoagent-rutis/README.md | 5 +- .../yoagent-rutis/plugins/yoagent.d.ts | 4 +- integrations/yoagent-rutis/src/languages.rs | 60 ++++++++++++++----- 4 files changed, 54 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 160fcbb..cfd9083 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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) — 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.7.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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; self-checks). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/` and `plugins/dsh/`, clippy per feature, docs, tests with the runtimes required, the `language_plugins` example; `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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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.7.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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; self-checks). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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/` and `plugins/dsh/`, clippy per feature, docs, tests with the runtimes required, the `language_plugins` example; `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`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 18bb4cd..9e40160 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -218,7 +218,10 @@ def apply(ctx, config): Text and blocks are exclusive in one answer (so return picked fields, not the `output` you were given). An image must be standard base64 of at most 10 MB, typed `image/png`, `image/jpeg`, `image/gif` or `image/webp` - (what every provider takes); anything else in `content` fails the answer. + (what every provider takes); anything else in `content` fails the answer + — except, in an `after_tool` edit, an image identical to one in + `output.content`: keeping what yoagent let in (`read_file` takes bmp, up + to 20 MB) never fails the edit. Stay under your provider's own limit too (Anthropic: 5 MB base64): an image it refuses sits in the history and fails every later request. diff --git a/integrations/yoagent-rutis/plugins/yoagent.d.ts b/integrations/yoagent-rutis/plugins/yoagent.d.ts index 8816dcc..466ef9f 100644 --- a/integrations/yoagent-rutis/plugins/yoagent.d.ts +++ b/integrations/yoagent-rutis/plugins/yoagent.d.ts @@ -174,7 +174,9 @@ export interface ToolOutput { /** * Keep the output (nothing), or replace parts of it: `text` replaces every * content block with one text block, `content` with the given blocks (so - * images can be kept, added or dropped). Not both — so do not return the + * images can be kept, added or dropped; a kept image — identical to one in + * `output.content` — passes as it is, a new one must meet ContentBlock's + * rules). Not both — so do not return the * `output` you were given (`{...output, text}` has both): pick the fields. */ export type OutputEdit = diff --git a/integrations/yoagent-rutis/src/languages.rs b/integrations/yoagent-rutis/src/languages.rs index c590035..c714bf4 100644 --- a/integrations/yoagent-rutis/src/languages.rs +++ b/integrations/yoagent-rutis/src/languages.rs @@ -526,8 +526,8 @@ impl HandlerImpl for RemoteHandler { }]; } if let Some(content) = edit.get("content") { - output.result.content = - content_blocks(content).ok_or_else(|| unexpected("after_tool", &value))?; + output.result.content = content_blocks(content, &output.result.content) + .ok_or_else(|| unexpected("after_tool", &value))?; } if let Some(details) = edit.get("details") { output.result.details = details.clone(); @@ -916,7 +916,7 @@ impl AgentTool for RemoteTool { let content = match (fields.get("text"), fields.get("content")) { (Some(Json::String(text)), _) => vec![Content::Text { text: text.clone() }], (Some(_), _) => return Err(unexpected()), - (None, Some(blocks)) => content_blocks(blocks).ok_or_else(unexpected)?, + (None, Some(blocks)) => content_blocks(blocks, &[]).ok_or_else(unexpected)?, (None, None) => vec![Content::Text { text: String::new(), }], @@ -960,8 +960,11 @@ const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; /// pi's, and MCP's bare blocks) — as yoagent content. `None` for anything /// else: not an array, an unknown block type, an extra or missing field, an /// image whose `mimeType` is not one of IMAGE_TYPES or whose `data` is not -/// standard base64 of 1 to MAX_IMAGE_BYTES bytes. -fn content_blocks(blocks: &Json) -> Option> { +/// standard base64 of 1 to MAX_IMAGE_BYTES bytes. An image identical to one +/// in `kept` (the output an `after_tool` edit was given) passes as it is: +/// yoagent let it in (`read_file` takes bmp, up to 20 MB), and keeping it +/// must not fail the edit. +fn content_blocks(blocks: &Json, kept: &[Content]) -> Option> { use base64::Engine as _; blocks .as_array()? @@ -976,6 +979,15 @@ fn content_blocks(blocks: &Json) -> Option> { "image" if fields.len() == 3 => { let data = field("data")?; let mime_type = field("mimeType")?; + let unchanged = kept.iter().any(|c| { + matches!(c, Content::Image { data: d, mime_type: m } if d == data && m == mime_type) + }); + if unchanged { + return Some(Content::Image { + data: data.to_string(), + mime_type: mime_type.to_string(), + }); + } // A decoded length bound before decoding: 3 bytes per 4 characters. let fits = data.len() / 4 * 3 <= MAX_IMAGE_BYTES + 2; let bytes = fits @@ -1006,15 +1018,18 @@ mod tests { #[test] fn content_blocks_take_text_and_images_in_yoagents_shape() { - let blocks = content_blocks(&json!([ - {"type": "text", "text": "see"}, - {"type": "image", "data": "iVBORw0KGgo=", "mimeType": "image/png"}, - ])) + let blocks = content_blocks( + &json!([ + {"type": "text", "text": "see"}, + {"type": "image", "data": "iVBORw0KGgo=", "mimeType": "image/png"}, + ]), + &[], + ) .unwrap(); assert!(matches!(&blocks[0], Content::Text { text } if text == "see")); assert!(matches!(&blocks[1], Content::Image { data, mime_type } if data == "iVBORw0KGgo=" && mime_type == "image/png")); - assert!(content_blocks(&json!([])).unwrap().is_empty()); + assert!(content_blocks(&json!([]), &[]).unwrap().is_empty()); for bad in [ json!("text"), json!([{"type": "text"}]), @@ -1023,6 +1038,8 @@ mod tests { json!([{"type": "image", "data": "", "mimeType": "image/png"}]), json!([{"type": "image", "data": "not base64!", "mimeType": "image/png"}]), json!([{"type": "image", "data": "AAAA", "mimeType": "text/plain"}]), + json!([{"type": "image", "data": "AAAA"}]), + json!([{"type": "file", "data": "AAAA", "mimeType": "image/png"}]), // Mis-padded, or padding in the middle: not base64. json!([{"type": "image", "data": "AAAAA", "mimeType": "image/png"}]), json!([{"type": "image", "data": "A=AA", "mimeType": "image/png"}]), @@ -1030,15 +1047,30 @@ mod tests { // A type providers do not take. json!([{"type": "image", "data": "AAAA", "mimeType": "image/svg+xml"}]), json!([{"type": "image", "data": "AAAA", "mimeType": "image/"}]), - // Over the size limit. + // Over the size limit: refused after decoding, and before it. json!([{"type": "image", "data": "A".repeat((MAX_IMAGE_BYTES + 3) / 3 * 4), "mimeType": "image/png"}]), - json!([{"type": "image", "data": "AAAA"}]), - json!([{"type": "file", "data": "AAAA", "mimeType": "image/png"}]), + json!([{"type": "image", "data": "A".repeat(MAX_IMAGE_BYTES / 3 * 4 + 8), "mimeType": "image/png"}]), ] { - assert!(content_blocks(&bad).is_none(), "{bad}"); + assert!(content_blocks(&bad, &[]).is_none(), "{bad}"); } } + #[test] + fn an_after_tool_edit_may_keep_an_image_yoagent_let_in() { + // read_file returns bmp; MCP tools other types: keeping one is no new image. + let kept = [Content::Image { + data: "Qk0=".into(), + mime_type: "image/bmp".into(), + }]; + let same = json!([{"type": "image", "data": "Qk0=", "mimeType": "image/bmp"}]); + assert!(content_blocks(&same, &[]).is_none()); + let blocks = content_blocks(&same, &kept).unwrap(); + assert!(matches!(&blocks[0], Content::Image { mime_type, .. } if mime_type == "image/bmp")); + // Changed data is a new image, checked as one. + let changed = json!([{"type": "image", "data": "Qk1=", "mimeType": "image/bmp"}]); + assert!(content_blocks(&changed, &kept).is_none()); + } + /// How the test handler's event delivery goes wrong. #[derive(Clone, Copy)] enum Breaks { From 10dcc42484351677b5fb3c9e23a08b840b264fc1 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 22:00:02 +0200 Subject: [PATCH 12/15] feat(rutis): plugin logs to the host, pi executeTool, mid-run checks, pi-latest CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bridge: the yoagent service gains log(level, message) → tracing target yoagent_rutis::plugin (8 KiB cap); the pi and dsh adapters report through it (else stderr). Test: tests/plugin_log_test.rs (own binary) - pi adapter: ctx.executeTool runs pi tools as nested calls (prepare, validate, tool_call with parentToolCallId, tool_result; never rejects); ctx.tools lists them; yoagent's tools are not reachable - pi adapter: a deciding event registered mid-run is caught at the next model request or tool call, and stops that run - CI: weekly pi-latest workflow runs pi_test against the newest pi Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .github/workflows/pi-latest.yml | 51 ++++++ CHANGELOG.md | 3 +- CLAUDE.md | 2 +- integrations/yoagent-rutis/Cargo.toml | 6 + integrations/yoagent-rutis/README.md | 13 +- .../plugins/dsh/dsh-tools-adapter.ts | 4 +- .../yoagent-rutis/plugins/pi/fixture-extra.ts | 26 +++ .../plugins/pi/pi-extensions-adapter.ts | 173 ++++++++++++++++-- .../yoagent-rutis/plugins/yoagent.d.ts | 8 + integrations/yoagent-rutis/src/languages.rs | 34 +++- integrations/yoagent-rutis/tests/pi_test.rs | 63 ++++++- .../yoagent-rutis/tests/plugin_log_test.rs | 124 +++++++++++++ 12 files changed, 478 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/pi-latest.yml create mode 100644 integrations/yoagent-rutis/tests/plugin_log_test.rs diff --git a/.github/workflows/pi-latest.yml b/.github/workflows/pi-latest.yml new file mode 100644 index 0000000..bdbfa1e --- /dev/null +++ b/.github/workflows/pi-latest.yml @@ -0,0 +1,51 @@ +name: pi latest + +# The pi extensions adapter (integrations/yoagent-rutis/plugins/pi) pins pi +# exactly (1.1.0) and imports pi's extension loader by its internal file path +# (dist/core/extensions/loader.js), which pi's export map does not promise. +# This job runs the adapter's end-to-end tests against the LATEST pi packages +# weekly, so a pi release that breaks the adapter shows up here before anyone +# bumps the pin. A red run means: check the adapter against that pi release. +# It never changes the pin or the lockfile. +on: + schedule: + # Tuesdays 04:23 UTC. + - cron: "23 4 * * 2" + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + MANIFEST: integrations/yoagent-rutis/Cargo.toml + PI: integrations/yoagent-rutis/plugins/pi + +permissions: + contents: read + +jobs: + pi-latest: + name: pi adapter vs latest pi + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: integrations/yoagent-rutis + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Install the pinned packages, then the latest pi on top + run: | + npm ci --prefix "$PI" + npm install --prefix "$PI" --no-save \ + @earendil-works/pi-coding-agent@latest \ + @earendil-works/pi-ai@latest \ + @earendil-works/pi-tui@latest \ + typebox@latest + echo "pi under test:" + npm ls --prefix "$PI" @earendil-works/pi-coding-agent @earendil-works/pi-ai @earendil-works/pi-tui typebox + - name: The adapter's tests against it + env: + YOAGENT_RUTIS_REQUIRE_RUNTIMES: "1" + run: cargo test --manifest-path "$MANIFEST" --features node --test pi_test diff --git a/CHANGELOG.md b/CHANGELOG.md index baa2551..fe3e53b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,9 @@ adheres to [Semantic Versioning](https://semver.org/). ### yoagent-rutis -- **pi extensions** (`plugins/pi/pi-extensions-adapter.ts`): the tools and tool policies of [pi](https://github.com/earendil-works/pi) coding-agent extensions, loaded unchanged with pi's own loader, as one yoagent handler following pi 1.1.0's semantics. `registerTool` → tools (pi's activation and first-registration-wins, `prepareArguments` and validation before the policies, only the judged arguments run), `setActiveTools` → an enforced allowlist, `tool_call` → `before_tool` (yoagent's built-ins under pi's names, arguments translated both ways, relative paths resolved where the policies look; an override of a built-in is enforced; `terminate` stops the run), `tool_result` → `after_tool` (a failing handler withholds the result), `input` → `on_input`, `before_agent_start` additions → a turn note. Fails closed: a load error, a failing `session_start`, an unfired deciding event (`context`, `message_end`, ...; unless `allowUnmapped`) or a pi tool named like a yoagent built-in (unless `withoutBuiltins`) refuses the load; other unmapped API (observer and session events, commands, renderers, providers, MCP servers) is reported, or refuses with `strict`. Example `pi_extensions` (any extension files; `--live` with DeepSeek; `--without`), test `pi_test`. +- **pi extensions** (`plugins/pi/pi-extensions-adapter.ts`): the tools and tool policies of [pi](https://github.com/earendil-works/pi) coding-agent extensions, loaded unchanged with pi's own loader, as one yoagent handler following pi 1.1.0's semantics. `registerTool` → tools (pi's activation and first-registration-wins, `prepareArguments` and validation before the policies, only the judged arguments run), `setActiveTools` → an enforced allowlist, `tool_call` → `before_tool` (yoagent's built-ins under pi's names, arguments translated both ways, relative paths resolved where the policies look; an override of a built-in is enforced; `terminate` stops the run), `tool_result` → `after_tool` (a failing handler withholds the result), `input` → `on_input`, `before_agent_start` additions → a turn note. Fails closed: a load error, a failing `session_start`, an unfired deciding event (`context`, `message_end`, ...; unless `allowUnmapped`) or a pi tool named like a yoagent built-in (unless `withoutBuiltins`) refuses the load; other unmapped API (observer and session events, commands, renderers, providers, MCP servers) is reported, or refuses with `strict`. Example `pi_extensions` (any extension files; `--live` with DeepSeek; `--without`), test `pi_test`. `ctx.executeTool` runs pi tools as nested calls (pi's pipeline; never rejects); a deciding event registered mid-run stops that run; adapter warnings reach the host's logs. CI: a weekly `pi latest` job runs the adapter's tests against the newest pi. - **Images in tool results, across ecosystems.** TypeScript and Python handlers' `call_tool` results and `after_tool` edits take `content` blocks in yoagent's JSON shape (`{"type": "image", "data": , "mimeType"}` next to text blocks) instead of `text`, so pictures cross the bridge both ways (`after_tool` already saw `output.content`). The dsh adapter turns dsh image blocks (references into dsh's attachment store) into yoagent images through the `attachments` service when one is loaded; the rutis-agent example reads a runner's `{"content": [...]}` value as blocks (rutis-agent results are otherwise text). Rust handlers already returned full yoagent tool results. Tests: `content_blocks` unit test, TypeScript/Python round trip in `languages_test`, dsh with and without a store in `dsh_test`. +- **Plugin logs reach the host.** The `yoagent` service gains `log(level, message)`: a TypeScript/Python plugin's diagnostics go to the host's `tracing` output (target `yoagent_rutis::plugin`) instead of the runtime process's stderr. The dsh and pi adapters use it. - Test fix: `dsh_test` no longer reads the abort file mid-write (empty) as the result. ## 0.25.0 (2026-10-08) diff --git a/CLAUDE.md b/CLAUDE.md index 9ad6975..6e7ad45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `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.7.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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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()`) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. 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); `UNSUPPORTED_ACTIONS` (`sendMessage`, `appendEntry`, ...) 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`/`executeTool` throw. 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"]`, 8 tests: 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`), cut at `MAX_LOG_CHARS` 8 KiB). `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.7.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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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. 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); `UNSUPPORTED_ACTIONS` (`sendMessage`, `appendEntry`, ...) 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 throw → error outcome, withheld), ids `/`, returns `{toolCall, result, isError, durationMs}`, 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); 8 original tests: 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/integrations/yoagent-rutis/Cargo.toml b/integrations/yoagent-rutis/Cargo.toml index 61683d0..65955ac 100644 --- a/integrations/yoagent-rutis/Cargo.toml +++ b/integrations/yoagent-rutis/Cargo.toml @@ -66,6 +66,8 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync" # loads plugins itself: that is the host's job. rutis-loader = { version = "0.7", features = ["node", "python"] } tempfile = "3" +# Captures the bridge's `log` output in its own test binary (tests/plugin_log_test.rs). +tracing-subscriber = { version = "0.3", default-features = false, features = ["registry"] } [[test]] name = "languages_test" @@ -79,6 +81,10 @@ required-features = ["node"] name = "pi_test" required-features = ["node"] +[[test]] +name = "plugin_log_test" +required-features = ["node"] + [[example]] name = "language_plugins" required-features = ["node", "python"] diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 631f75a..95eed82 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -230,6 +230,11 @@ def apply(ctx, config): return { content: [{ type: 'text', text: 'the chart' }, { type: 'image', data: png.toString('base64'), mimeType: 'image/png' }] } } ``` +- **Logs reach the host.** `yoagent.log(level, message)` (`error`, + `warn`, `info`, `debug`; fire-and-forget; over 8 KiB cut) writes to the + host's `tracing` output under the target `yoagent_rutis::plugin`, where a + terminal or service host shows it — a runtime process's own stderr may go + nowhere. Absent on older hosts: fall back to `console.warn`. - **The bridge never loads plugins**: the host does, typically with [rutis-loader](https://crates.io/crates/rutis-loader) rows, and must share `yoagent` in the loader's catalog (`catalog.register_shared("yoagent")` or @@ -351,6 +356,7 @@ onto one handler, following pi 1.1.0's own runner and agent loop: | pi | yoagent | |---|---| | `pi.registerTool` | a tool, while pi would activate it (exposure `direct` / `model-only`, `defaultActive` not false, in the `setActiveTools` allowlist if one was set; the first registration of a name wins). Arguments go through `prepareArguments` and pi's validation before any policy sees them, and `execute` runs only with the arguments the policies left — a call another handler rewrote afterwards is not run. A throw or `isError` is an error result; text and image blocks pass through (pi's shape is yoagent's) | +| `ctx.executeTool` | another pi tool, the way pi runs a nested call: prepared, validated, judged by `tool_call` (with `parentToolCallId`), its result through `tool_result`; never rejects (an unknown tool, a block or a throw is `isError`). yoagent's own tools are not reachable from it; `ctx.tools` lists the callable ones | | `pi.setActiveTools` | an allowlist over the tools pi knows — yoagent's built-ins under pi's names and the extensions' tools: those outside it are not offered and their calls are denied. Tools pi does not know (MCP tools, sub-agents, the host's own) are not affected, as in pi. As in pi, a tool named in it is activated even when `defaultActive` is false (not when hidden), and a tool registered later that pi would activate joins it. `getActiveTools` / `getAllTools` answer from the same view | | `on("tool_call")` | `before_tool` for every call. yoagent's built-ins are judged under pi's names (`bash`, `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit` with `edits`, `search`→`grep` with `glob` / `ignoreCase` (yoagent's search is case-insensitive unless asked, pi's grep the reverse), `list_files`→`find`; config `toolNames` adds more), with a relative `path` resolved against `cwd` first so the tool acts where the policy looked (yoagent's built-ins only: another tool's `path` may be a repository's). `{ block }` denies (`terminate: true` also stops the run at its next model request — stricter than pi, which ends only a batch whose results all set it); in-place changes to `event.input` rewrite the arguments, and one the yoagent tool cannot take (a second edit, a `timeout` on `bash`) denies the call; a throwing handler blocks, as in pi | | `on("tool_result")` | `after_tool`: content, details and isError edits, chained; only the fields a handler set are applied (a replaced content keeps its text and image blocks). A throwing handler **withholds the result** — unlike pi, which skips it: a failed redaction must not let the raw output through | @@ -367,15 +373,16 @@ the host lists it in `allowUnmapped`; and a pi tool named exactly like a yoagent built-in (pi's sandboxed `bash`) unless the host left that built-in out and says so in `withoutBuiltins` — otherwise yoagent's own tool would win the merge and run unsandboxed. Anything of this kind registered after -load (inside a handler) stops the adapter instead: every later call denied, -prompt rejected, run stopped. +load (inside a handler or a tool) is caught at the next model request or +tool call and stops the adapter instead: that run stopped, every later call +denied, prompt rejected. **Overrides.** An extension tool that replaces one of pi's built-ins under another name than yoagent's (`read`, `write`, `edit`, `grep`, `find`) makes the adapter deny yoagent's counterpart (`read_file`, ...), so the model cannot go around it. -**What is only reported** (a warning on the Node process's stderr; config +**What is only reported** (a warning in the host's `tracing` logs, through the bridge's `log`; config `strict: true` makes it a load failure): every other unfired event — observers such as `agent_end` or `tool_execution_*`, pi's session events, the boundary events `turn_end` / `agent_before_settle` — commands, diff --git a/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts b/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts index 294078e..ab8666a 100644 --- a/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts +++ b/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts @@ -143,7 +143,9 @@ export default definePlugin({ return { type: 'image', data: Buffer.from(stored.data).toString('base64'), mimeType: stored.ref.mediaType } } catch (error) { if (signal.aborted) throw error - console.warn(`[dsh] image ${ref.attachmentId} could not be read: ${error}`) + const message = `[dsh] image ${ref.attachmentId} could not be read: ${error}` + if (typeof yoagent.log === 'function') yoagent.log('warn', message).catch(() => console.warn(message)) + else console.warn(message) return { type: 'text', text: label } } } diff --git a/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts index dbfc325..be58bd2 100644 --- a/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts +++ b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts @@ -60,6 +60,32 @@ export default function (pi: ExtensionAPI) { }, }) + // Nested calls through ctx.executeTool: a pi tool, one a policy blocks, and + // one of yoagent's tools (not reachable). Records each outcome. + pi.registerTool({ + name: 'pi_compose', + label: 'Compose', + description: 'Calls other tools.', + parameters: Type.Object({}), + async execute(_id, _params, _signal, _onUpdate, ctx) { + const callable = ctx.tools.map((t: { name: string }) => t.name) + const outcomes = [] + for (const [name, args] of [ + ['pi_echo', { text: 'nested SECRET' }], + ['pi_echo', { text: 'boom' }], + ['bash', { command: 'echo hi' }], + ] as const) { + const o = await ctx.executeTool(name, args) + const text = o.result.content.map((b: { text?: string }) => b.text ?? '').join('') + outcomes.push(`${o.toolCall.id} ${name} ${o.isError ? 'error' : 'ok'}: ${text}`) + } + return { + content: [{ type: 'text', text: `callable: ${callable.includes('pi_echo')}\n${outcomes.join('\n')}` }], + details: undefined, + } + }, + }) + pi.on('tool_call', async (event) => { if (event.toolName === 'pi_echo' && event.input.text === 'boom') throw new Error('policy crashed') // yoagent's search, as pi's grep: include is pi's glob, and an unset diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts index 2a5fb59..d7335fd 100644 --- a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -25,6 +25,12 @@ // run. A throw or `isError` is an error result; // text and image blocks pass through as they are // (pi's shape is yoagent's). +// `ctx.executeTool` calls another pi tool the way pi +// does (prepared, validated, judged by `tool_call` +// with `parentToolCallId`, results through +// `tool_result`; never rejects); yoagent's own tools +// are not reachable from it. `ctx.tools` lists the +// callable ones. // A tool that overrides one of pi's built-ins // (`read`, `edit`, ...) makes the adapter deny // yoagent's counterpart (`read_file`, ...), so the @@ -85,10 +91,12 @@ // `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 is reported then, -// and a deciding one stops the adapter: every later call denied. Other -// runtime actions (`pi.sendMessage`, `pi.appendEntry`, ...) throw "not -// available in yoagent". `ctx.hasUI` is false and `ctx.ui` behaves as in +// 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). Other runtime actions +// (`pi.sendMessage`, `pi.appendEntry`, ...) throw "not available in +// yoagent". `ctx.hasUI` is false and `ctx.ui` behaves as in // pi's print mode: `confirm` answers false, `select` and `input` nothing, so // a policy that would ask the user denies instead. @@ -214,6 +222,11 @@ const PLAIN_THEME = new Proxy({} as Record, { key === 'then' ? undefined : key === 'name' ? 'plain' : (...args: unknown[]) => args[args.length - 1], }) +/** Where diagnostics go: the host's logs through `yoagent.log` once `apply` has the service, else stderr. */ +let report = (level: 'error' | 'warn' | 'info' | 'debug', message: string) => { + console.warn(message) +} + /** pi's print-mode UI (its `noOpUIContext`): nothing to show, no one to ask. */ const NO_UI = new Proxy( { @@ -222,7 +235,8 @@ const NO_UI = new Proxy( input: async () => undefined, editor: async () => undefined, custom: async () => undefined, - notify: (message: string, level?: string) => console.warn(`[pi ${level ?? 'info'}] ${message}`), + notify: (message: string, level?: string) => + report(level === 'error' ? 'error' : level === 'warning' ? 'warn' : 'info', `[pi ${level ?? 'info'}] ${message}`), onTerminalInput: () => () => {}, getEditorText: () => '', getAllThemes: () => [], @@ -347,6 +361,12 @@ export default definePlugin({ }, async apply(ctx, config) { const yoagent = ctx.use('yoagent') + if (typeof yoagent.log === 'function') { + const log = yoagent.log.bind(yoagent) + report = (level, message) => { + log(level, message).catch(() => console.warn(message)) + } + } const cwd = config.cwd ?? process.cwd() const names = new Map(Object.entries({ ...TOOL_NAMES, ...config.toolNames })) const withoutBuiltins = new Set(config.withoutBuiltins ?? []) @@ -358,7 +378,7 @@ export default definePlugin({ if (loaded.errors.length > 0) { throw new Error(`pi extensions failed to load: ${loaded.errors.map((e) => `${e.path}: ${e.error}`).join('; ')}`) } - for (const w of loaded.warnings ?? []) console.warn(`[pi] ${short(w.path)}: ${w.warning}`) + for (const w of loaded.warnings ?? []) report('warn', `[pi] ${short(w.path)}: ${w.warning}`) const extensions = loaded.extensions const runtime = loaded.runtime @@ -396,13 +416,121 @@ export default definePlugin({ throw new Error('ctx.compact() is not available in yoagent') }, }) - const toolContext = (signal: AbortSignal) => ({ - ...context(signal), - tools: [], - executeTool: async () => { - throw new Error('ctx.executeTool() is not available in yoagent') - }, - }) + /** + * The pi tools `ctx.executeTool` can call: pi's callable exposures + * (`direct` while available, `codemode`, `deferred`; never `model-only` + * or `hidden`). yoagent's own tools are not reachable from here. + */ + const callable = () => { + const offered = available() + return new Map( + [...registered()].filter(([name, tool]) => { + const exposure = tool.exposure ?? 'direct' + return exposure === 'direct' ? offered.has(name) : exposure === 'codemode' || exposure === 'deferred' + }), + ) + } + + type Outcome = { + toolCall: { id: string; name: string; arguments: unknown } + result: { content: Block[]; details: unknown } + isError: boolean + durationMs?: number + } + + /** + * A nested call, as pi's `executeTool` makes it: prepared and validated, + * judged by the `tool_call` handlers (with `parentToolCallId`), run, and + * passed through the `tool_result` handlers. Never rejects: an unknown + * tool, a validation error, a block or a throw is `isError`. + */ + const executeNested = async ( + runId: string, + parentId: string, + id: string, + name: string, + args: unknown, + signal: AbortSignal, + depth: number, + ): Promise => { + const failed = (text: string, input: unknown = args): Outcome => ({ + toolCall: { id, name, arguments: input }, + result: { content: [{ type: 'text', text }], details: undefined }, + isError: true, + }) + if (depth > 8) return failed('nested tool calls are limited to 8 levels') + const tool = callable().get(name) + if (!tool) return failed(`Tool ${name} not found (yoagent's own tools cannot be called from a pi tool)`) + let input: Args + try { + const copy = structuredClone(args) + const prepared = tool.prepareArguments ? tool.prepareArguments(copy) : copy + input = validateToolArguments(tool as never, { name, arguments: prepared } as never) as Args + } catch (error) { + return failed(message(error)) + } + for (const { ext, fn } of handlers('tool_call')) { + const event = { type: 'tool_call', toolCallId: id, parentToolCallId: parentId, toolName: name, input } + let result: { block?: boolean; reason?: string; terminate?: boolean } | undefined + try { + result = (await fn(event, context(signal))) as typeof result + } catch (error) { + return failed(`pi extension ${short(ext.path)} failed: ${message(error)}`, input) + } + if (result?.block) { + const reason = result.reason ?? `blocked by pi extension ${short(ext.path)}` + if (result.terminate === true) terminated.set(runId, reason) + return failed(reason, input) + } + } + const started = performance.now() + let out: { content?: Block[]; details?: unknown; isError?: boolean } + try { + out = await tool.execute(id, input, signal, undefined, toolContext(signal, runId, id, depth + 1)) + } catch (error) { + return { ...failed(message(error), input), durationMs: performance.now() - started } + } + const event = { + type: 'tool_result', + toolCallId: id, + parentToolCallId: parentId, + toolName: name, + input, + content: out.content ?? [], + details: out.details, + isError: out.isError === true, + } + for (const { ext, fn } of handlers('tool_result')) { + try { + const edit = (await fn(event, context(signal))) as { content?: Block[]; details?: unknown; isError?: boolean } | undefined + if (edit?.content !== undefined) event.content = edit.content + if (edit?.details !== undefined) event.details = edit.details + if (edit?.isError !== undefined) event.isError = edit.isError + } catch (error) { + // As at the top level: a failed redaction must not let the raw output through. + return failed(`pi tool_result handler ${short(ext.path)} failed, the result is withheld: ${message(error)}`, input) + } + } + return { + toolCall: { id, name, arguments: input }, + result: { content: event.content, details: event.details }, + isError: event.isError, + durationMs: performance.now() - started, + } + } + + /** A tool's `ctx`: the extension context, plus `tools` and `executeTool` for nested calls. */ + const toolContext = (signal: AbortSignal, runId: string, callId: string, depth = 0) => { + let nested = 0 + return { + ...context(signal), + get tools() { + return [...callable().values()] + }, + executeTool: (name: string, args: unknown, options?: { signal?: AbortSignal }) => + executeNested(runId, callId, `${callId}/${++nested}`, name, args, options?.signal ?? signal, depth), + } + } /** Every handler of `event`, in extension load and registration order. */ const handlers = (event: string) => @@ -535,12 +663,12 @@ export default definePlugin({ `${newDeciding.join('; ')} (list an event in allowUnmapped to accept that it goes unenforced)` if (atLoad) throw new Error(text) refusal = `the pi extensions adapter stopped: ${text}` - console.warn(`[pi] ${refusal}`) + report('warn', `[pi] ${refusal}`) } 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}`) - console.warn(`[pi] ${text}`) + report('warn', `[pi] ${text}`) } } @@ -555,7 +683,7 @@ export default definePlugin({ `one too, it runs instead of the pi tool. Leave yoagent's out and list it in withoutBuiltins` if (atLoad) throw new Error(text) refusal = `the pi extensions adapter stopped: ${text}` - console.warn(`[pi] ${refusal}`) + report('warn', `[pi] ${refusal}`) } } @@ -582,7 +710,7 @@ export default definePlugin({ | { systemPrompt?: unknown; message?: unknown } | undefined if (result?.message !== undefined) { - console.warn(`[pi] ${short(ext.path)} before_agent_start: its message dropped (yoagent cannot inject one)`) + report('warn', `[pi] ${short(ext.path)} before_agent_start: its message dropped (yoagent cannot inject one)`) } if (result?.systemPrompt !== undefined) { if (typeof result.systemPrompt !== 'string' || !result.systemPrompt.includes(PROMPT_MARK)) { @@ -592,7 +720,7 @@ export default definePlugin({ } } catch (error) { // As in pi: one handler's failure is reported, the others still count. - console.warn(`[pi] ${short(ext.path)} before_agent_start skipped, so it does not apply: ${message(error)}`) + report('warn', `[pi] ${short(ext.path)} before_agent_start skipped, so it does not apply: ${message(error)}`) } } const added = systemPrompt.split(PROMPT_MARK).map((part) => part.trim()).filter(Boolean) @@ -614,7 +742,7 @@ export default definePlugin({ try { await fn({ type: 'session_shutdown', reason: 'quit' }, context()) } catch (error) { - console.warn(`[pi] ${short(ext.path)} session_shutdown: ${message(error)}`) + report('warn', `[pi] ${short(ext.path)} session_shutdown: ${message(error)}`) } } }) @@ -655,7 +783,7 @@ export default definePlugin({ } } try { - const out = await tool.execute(call.call_id, call.args, call.signal, undefined, toolContext(call.signal)) + const out = await tool.execute(call.call_id, call.args, call.signal, undefined, toolContext(call.signal, call.run_id, call.call_id)) if (out.isError === true) return { text: text(out.content), details: out.details ?? null, is_error: true } return { content: blocks(out.content), details: out.details ?? null } } catch (error) { @@ -664,6 +792,9 @@ export default definePlugin({ }, async before_tool(call: ToolCall & Cancellable) { + // Registrations made during the run: caught before the next call, not the next run. + checkUnmapped(false) + checkShadowing(false) if (refusal) return { deny: refusal } const piTools = available() const own = piTools.get(call.tool) @@ -809,6 +940,8 @@ export default definePlugin({ }, async before_model(turn) { + checkUnmapped(false) + checkShadowing(false) if (refusal) return { stop: refusal } const stop = terminated.get(turn.run_id) if (stop) return { stop } diff --git a/integrations/yoagent-rutis/plugins/yoagent.d.ts b/integrations/yoagent-rutis/plugins/yoagent.d.ts index 466ef9f..0798115 100644 --- a/integrations/yoagent-rutis/plugins/yoagent.d.ts +++ b/integrations/yoagent-rutis/plugins/yoagent.d.ts @@ -39,6 +39,14 @@ export interface Yoagent { * `on_input` rejects, its `after_tool` withholds the result. */ register(name: string, handler: Handler, options?: Options): () => void + /** + * Write a diagnostic to the host's logs (its `tracing` output, target + * `yoagent_rutis::plugin`) rather than this process's stderr, which a + * terminal or service host may not show. Levels `error`, `warn`, `info`, + * `debug`; messages over 8 KiB are cut. Fire-and-forget. Absent on hosts + * older than yoagent-rutis 0.2: fall back to `console.warn`. + */ + log?(level: 'error' | 'warn' | 'info' | 'debug', message: string): Promise } export interface Options { diff --git a/integrations/yoagent-rutis/src/languages.rs b/integrations/yoagent-rutis/src/languages.rs index c714bf4..1a97855 100644 --- a/integrations/yoagent-rutis/src/languages.rs +++ b/integrations/yoagent-rutis/src/languages.rs @@ -142,6 +142,7 @@ impl HostDispatch for Service { fn invoke(&self, method: &str, args: Value) -> Reply { match method { "register" => self.register(args), + "log" => log(args), other => Err(invalid(format!( "the yoagent service has no method `{other}`" ))), @@ -151,11 +152,40 @@ impl HostDispatch for Service { fn methods(&self) -> Option { // Synchronous: `register` returns the disposer the plugin passes to // `ctx.effect`. It reads the handler's members back while the plugin - // waits, on the same call chain. - Some(json!({ "register": "sync" })) + // waits, on the same call chain. `log` is fire-and-forget. + Some(json!({ "register": "sync", "log": "async" })) } } +/// Longest message `log` writes; the rest is cut (a plugin cannot flood the host's logs). +const MAX_LOG_CHARS: usize = 8 * 1024; + +/// `log(level, message)`: a plugin's diagnostic in the host's `tracing` +/// output (target `yoagent_rutis::plugin`), not on the plugin process's +/// stderr. Levels `error`, `warn`, `info`, `debug`; any other is `warn`. +fn log(args: Value) -> Reply { + let mut args = args.list()?.into_iter(); + let mut text = |what: &str| -> Result { + match args.next() { + Some(value) => session::decode::(value.json()?), + None => Err(invalid(format!("log(level, message): missing the {what}"))), + } + }; + let level = text("level")?; + let message = text("message")?; + let message: String = match message.char_indices().nth(MAX_LOG_CHARS) { + Some((cut, _)) => format!("{}… (cut)", &message[..cut]), + None => message, + }; + match level.as_str() { + "error" => tracing::error!(target: "yoagent_rutis::plugin", "{message}"), + "info" => tracing::info!(target: "yoagent_rutis::plugin", "{message}"), + "debug" => tracing::debug!(target: "yoagent_rutis::plugin", "{message}"), + _ => tracing::warn!(target: "yoagent_rutis::plugin", "{message}"), + } + Ok(Value::Undefined) +} + /// `register`'s third argument. #[derive(Default, serde::Deserialize)] #[serde(default, deny_unknown_fields)] diff --git a/integrations/yoagent-rutis/tests/pi_test.rs b/integrations/yoagent-rutis/tests/pi_test.rs index 57d1d44..625f35b 100644 --- a/integrations/yoagent-rutis/tests/pi_test.rs +++ b/integrations/yoagent-rutis/tests/pi_test.rs @@ -389,6 +389,8 @@ async fn pi_semantics_on_the_less_common_paths() { ("bash", json!({"command": "echo bounded"})), // Relative: resolved against the extensions' cwd, not the test's. ("write_file", json!({"path": "rel.txt", "content": "here"})), + // Nested calls from a pi tool. + ("pi_compose", json!({})), // Blocked with terminate: the run stops before its next request. ("bash", json!({"command": "echo stop-now"})), ]), @@ -505,9 +507,26 @@ async fn pi_semantics_on_the_less_common_paths() { std::fs::read_to_string(project.path().join("rel.txt")).unwrap(), "here" ); + // ctx.executeTool: a nested pi tool runs (its result redacted, its id under + // the parent's), a policy can block one, and yoagent's tools are out of reach. + let (_, text, is_error) = result(15); + assert!(!is_error, "{results:?}"); + assert!(text.contains("callable: true"), "{text}"); + assert!( + text.contains("/1 pi_echo ok: pi echo: nested [redacted]"), + "{text}" + ); + assert!( + text.contains("/2 pi_echo error:") && text.contains("policy crashed"), + "{text}" + ); + assert!( + text.contains("/3 bash error: Tool bash not found"), + "{text}" + ); // terminate: true denied the call and stopped the run before its next request. assert!( - result(15).2 && result(15).1.contains("stopping the run"), + result(16).2 && result(16).1.contains("stopping the run"), "{results:?}" ); assert_eq!(seen_now.len(), 1, "the run stopped: {seen_now:?}"); @@ -853,3 +872,45 @@ async fn a_pi_tool_runs_only_the_arguments_its_policies_judged() { ); host.root.shutdown().await.unwrap(); } + +#[tokio::test(flavor = "multi_thread")] +async fn a_deciding_event_registered_mid_run_stops_that_run() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let ext = extension( + dir.path(), + "late-context.ts", + r#"import { Type } from 'typebox' +export default function (pi) { + pi.registerTool({ + name: 'pi_arm', label: 'arm', description: 'registers a context handler', parameters: Type.Object({}), + async execute() { + pi.on('context', () => ({ messages: [] })) + return { content: [{ type: 'text', text: 'armed' }], details: undefined } + }, + }) +} +"#, + ); + let host = host(&runtime).await; + host.load(adapter_row(json!({ "extensions": [ext] }))).await; + let (agent, seen) = agent(vec![ + call("pi_arm", json!({})), + call("pi_arm", json!({})), + text("never requested"), + ]); + let mut agent = agent.with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "go").await; + // The handler appeared during the run: its next model request is stopped. + assert_eq!(results.len(), 1, "{results:?}"); + assert_eq!( + seen.lock().unwrap().len(), + 1, + "the second request never went out" + ); + let last = format!("{:?}", agent.messages().last()); + assert!(last.contains("context"), "{last}"); + host.root.shutdown().await.unwrap(); +} diff --git a/integrations/yoagent-rutis/tests/plugin_log_test.rs b/integrations/yoagent-rutis/tests/plugin_log_test.rs new file mode 100644 index 0000000..b66a9d6 --- /dev/null +++ b/integrations/yoagent-rutis/tests/plugin_log_test.rs @@ -0,0 +1,124 @@ +//! A TypeScript plugin's diagnostics reach the host's `tracing` output +//! through the bridge's `log` method (target `yoagent_rutis::plugin`), not +//! only the plugin process's stderr: the pi adapter reports what it ignores. +//! +//! Its own test binary with a single test: it installs a global subscriber +//! (the bridge logs from Tokio's worker threads). Needs Node 24+ and `npm ci` +//! in `plugins/pi/`; without them it prints `SKIPPED:` and passes, or fails +//! with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use rutis::Ctx; +use rutis_bridge::runtime::LocalRuntime; +use rutis_loader::{ + Chain, Layer, LoaderOptions, LoaderPlugin, Patch, RuntimeResolver, RuntimeRowsPlugin, + ServiceCatalog, +}; +use serde_json::json; +use tracing_subscriber::layer::SubscriberExt; +use yoagent_rutis::RutisBridge; + +/// Every event as `target level message`. +#[derive(Clone, Default)] +struct CapturedLogs(Arc>>); + +impl tracing_subscriber::Layer for CapturedLogs { + fn on_event(&self, event: &tracing::Event<'_>, _: tracing_subscriber::layer::Context<'_, S>) { + struct Message(String); + impl tracing::field::Visit for Message { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.0 = format!("{value:?}"); + } + } + } + let mut message = Message(String::new()); + event.record(&mut message); + let meta = event.metadata(); + self.0 + .lock() + .unwrap() + .push(format!("{} {} {}", meta.target(), meta.level(), message.0)); + } +} + +fn pi_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins/pi") +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_plugin_warning_reaches_the_hosts_logs() { + let runtime = pi_dir().join("node_modules/@arcships/rutis-runtime"); + if !runtime.join("package.json").exists() { + if std::env::var_os("YOAGENT_RUTIS_REQUIRE_RUNTIMES").is_some_and(|v| v == "1") { + panic!("the pi runtime is required but unavailable: run `npm ci` in plugins/pi/"); + } + eprintln!("SKIPPED: run `npm ci` in plugins/pi/"); + return; + } + let logs = CapturedLogs::default(); + tracing::subscriber::set_global_default(tracing_subscriber::registry().with(logs.clone())) + .unwrap(); + + let root = Ctx::root().unwrap(); + let bridge = RutisBridge::install(&root).unwrap(); + let mut catalog = ServiceCatalog::new(); + catalog.register_shared("yoagent"); + let node = LocalRuntime::node(&runtime, pi_dir().join("package.json")); + let resolver = Arc::new(RuntimeResolver::node(node.handle()).with_catalog(&catalog)); + root.plugin(node); + let plugin = LoaderPlugin::new( + Chain::new().with_shared(resolver.clone()), + LoaderOptions { + catalog, + ..LoaderOptions::default() + }, + ); + let loader = plugin.handle(); + root.plugin(plugin).await.unwrap(); + root.plugin(RuntimeRowsPlugin::new(resolver)); + // The fixture registers a command: the adapter reports it as not available. + let patches: Vec = serde_json::from_value(json!([{ "insert": [{ + "id": "pi", + "name": pi_dir().join("pi-extensions-adapter.ts"), + "config": { "extensions": [pi_dir().join("fixture-extension.ts")] }, + }] }])) + .unwrap(); + let report = loader + .reconcile(vec![Layer::new("rows", patches)], None) + .await + .unwrap(); + assert!(report.failures.is_empty(), "{report:?}"); + + let found = tokio::time::timeout(Duration::from_secs(60), async { + loop { + let hit = logs + .0 + .lock() + .unwrap() + .iter() + .find(|l| { + l.starts_with("yoagent_rutis::plugin WARN") && l.contains("command /fixture") + }) + .cloned(); + if let Some(line) = hit { + return line; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "no plugin warning in the host's logs: {:?}", + logs.0.lock().unwrap() + ) + }); + assert!(found.contains("not available in yoagent"), "{found}"); + let _ = bridge; + root.shutdown().await.unwrap(); +} From 469426fcb035c57170ce2d4e23484047b8e45325 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 22:07:56 +0200 Subject: [PATCH 13/15] =?UTF-8?q?fix(rutis):=20review=20=E2=80=94=20pi-lat?= =?UTF-8?q?est=20version=20print,=20nested=20errors=20through=20tool=5Fres?= =?UTF-8?q?ult,=20log=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pi-latest workflow: print versions from the installed packages (npm ls exits 1 on a version off the exact pin, which is the case under test) - executeTool: a nested tool's throw is an error result that still goes through the tool_result handlers (as pi); missing args are {}; refused once the adapter stopped; checks re-run per nested call; error details {} - log: rutis gives a missing method a throwing stand-in, so adapters wrap the call (try + .catch) and fall back to stderr - README points to the UI-as-plugin-services design (yoyo-meme/yo#3 §7) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .github/workflows/pi-latest.yml | 7 +++-- CLAUDE.md | 2 +- integrations/yoagent-rutis/README.md | 17 ++++++++---- .../plugins/dsh/dsh-tools-adapter.ts | 8 ++++-- .../yoagent-rutis/plugins/pi/fixture-extra.ts | 9 +++++++ .../plugins/pi/pi-extensions-adapter.ts | 27 ++++++++++++------- .../yoagent-rutis/plugins/yoagent.d.ts | 5 ++-- integrations/yoagent-rutis/src/languages.rs | 2 +- integrations/yoagent-rutis/tests/pi_test.rs | 8 ++++++ 9 files changed, 63 insertions(+), 22 deletions(-) diff --git a/.github/workflows/pi-latest.yml b/.github/workflows/pi-latest.yml index bdbfa1e..3763dc3 100644 --- a/.github/workflows/pi-latest.yml +++ b/.github/workflows/pi-latest.yml @@ -43,8 +43,11 @@ jobs: @earendil-works/pi-ai@latest \ @earendil-works/pi-tui@latest \ typebox@latest - echo "pi under test:" - npm ls --prefix "$PI" @earendil-works/pi-coding-agent @earendil-works/pi-ai @earendil-works/pi-tui typebox + # Versions read from the installed packages: `npm ls` exits 1 on a + # version that differs from package.json's exact pin, which is the point here. + for p in @earendil-works/pi-coding-agent @earendil-works/pi-ai @earendil-works/pi-tui typebox; do + echo "$p $(node -p "require('./$PI/node_modules/$p/package.json').version")" + done - name: The adapter's tests against it env: YOAGENT_RUTIS_REQUIRE_RUNTIMES: "1" diff --git a/CLAUDE.md b/CLAUDE.md index 6e7ad45..982c1bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`), cut at `MAX_LOG_CHARS` 8 KiB). `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.7.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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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. 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); `UNSUPPORTED_ACTIONS` (`sendMessage`, `appendEntry`, ...) 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 throw → error outcome, withheld), ids `/`, returns `{toolCall, result, isError, durationMs}`, 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); 8 original tests: 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`), cut at `MAX_LOG_CHARS` 8192 characters; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`). `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.7.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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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. 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); `UNSUPPORTED_ACTIONS` (`sendMessage`, `appendEntry`, ...) 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 95eed82..d578f5b 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -231,10 +231,12 @@ def apply(ctx, config): } ``` - **Logs reach the host.** `yoagent.log(level, message)` (`error`, - `warn`, `info`, `debug`; fire-and-forget; over 8 KiB cut) writes to the - host's `tracing` output under the target `yoagent_rutis::plugin`, where a - terminal or service host shows it — a runtime process's own stderr may go - nowhere. Absent on older hosts: fall back to `console.warn`. + `warn`, `info`, `debug`; fire-and-forget; over 8192 characters cut) + writes to the host's `tracing` output under the target + `yoagent_rutis::plugin`, where a terminal or service host shows it — a + runtime process's own stderr may go nowhere. On an older host rutis's + stand-in for the method throws: wrap the call and fall back to + `console.warn`. - **The bridge never loads plugins**: the host does, typically with [rutis-loader](https://crates.io/crates/rutis-loader) rows, and must share `yoagent` in the loader's catalog (`catalog.register_shared("yoagent")` or @@ -390,7 +392,12 @@ shortcuts, flags, renderers, model providers, virtual models and MCP servers. Other runtime actions (`pi.sendMessage`, `pi.appendEntry`, ...) throw "not available in yoagent". There is no UI: `ctx.hasUI` is false and `ctx.ui` behaves as in pi's print mode (`confirm` answers false), so a -policy that would ask the user denies. +policy that would ask the user denies. Commands, dialogs and session +history are planned as host-level plugin services — `ui` and `commands` +provided by the attached client, `session` by the host that owns the +session — which the adapter would route `ctx.ui.*`, `registerCommand` and +the session calls to; without them it stays in print mode (design: yo's +`docs/WEB-UI-DESIGN.md` §7, [yoyo-meme/yo#3](https://github.com/yoyo-meme/yo/pull/3); not built). **Host setup.** Install the bridge's extension with `.require_policy()`, so a run that starts before the adapter registered (or after it failed to diff --git a/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts b/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts index ab8666a..0d65b27 100644 --- a/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts +++ b/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts @@ -144,8 +144,12 @@ export default definePlugin({ } catch (error) { if (signal.aborted) throw error const message = `[dsh] image ${ref.attachmentId} could not be read: ${error}` - if (typeof yoagent.log === 'function') yoagent.log('warn', message).catch(() => console.warn(message)) - else console.warn(message) + // On a host without `log`, rutis's stand-in throws: fall back either way. + try { + yoagent.log?.('warn', message)?.catch(() => console.warn(message)) + } catch { + console.warn(message) + } return { type: 'text', text: label } } } diff --git a/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts index be58bd2..ac87010 100644 --- a/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts +++ b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts @@ -74,6 +74,10 @@ export default function (pi: ExtensionAPI) { ['pi_echo', { text: 'nested SECRET' }], ['pi_echo', { text: 'boom' }], ['bash', { command: 'echo hi' }], + // A throw: its error text still goes through the redaction. + ['pi_fail', { why: 'SECRET leaked' }], + // No arguments: an empty object, as in pi. + ['pi_dynamic', undefined], ] as const) { const o = await ctx.executeTool(name, args) const text = o.result.content.map((b: { text?: string }) => b.text ?? '').join('') @@ -103,6 +107,11 @@ export default function (pi: ExtensionAPI) { return undefined }) + // Scoped to pi_fail: shows a nested call's thrown error reached tool_result. + pi.on('tool_result', async (event) => + event.toolName === 'pi_fail' && event.isError ? { content: [{ type: 'text', text: 'pi_fail error seen' }] } : undefined, + ) + // A details-only edit must keep the content (images included). pi.on('tool_result', async (event) => (event.toolName === 'read' ? { details: { seen: true } } : undefined)) // A redaction that fails: the result is withheld. diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts index d7335fd..0aab532 100644 --- a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -361,10 +361,13 @@ export default definePlugin({ }, async apply(ctx, config) { const yoagent = ctx.use('yoagent') - if (typeof yoagent.log === 'function') { - const log = yoagent.log.bind(yoagent) - report = (level, message) => { - log(level, message).catch(() => console.warn(message)) + // rutis gives every service method a stand-in, one that throws when the + // host lacks it: so `log` is called, and a throw or a rejection falls back. + report = (level, message) => { + try { + yoagent.log?.(level, message)?.catch(() => console.warn(message)) + } catch { + console.warn(message) } } const cwd = config.cwd ?? process.cwd() @@ -455,15 +458,19 @@ export default definePlugin({ ): Promise => { const failed = (text: string, input: unknown = args): Outcome => ({ toolCall: { id, name, arguments: input }, - result: { content: [{ type: 'text', text }], details: undefined }, + result: { content: [{ type: 'text', text }], details: {} }, isError: true, }) - if (depth > 8) return failed('nested tool calls are limited to 8 levels') + checkUnmapped(false) + checkShadowing(false) + if (refusal) return failed(refusal) + if (depth >= 8) return failed('nested tool calls are limited to 8 levels') const tool = callable().get(name) if (!tool) return failed(`Tool ${name} not found (yoagent's own tools cannot be called from a pi tool)`) let input: Args try { - const copy = structuredClone(args) + // As pi: missing arguments are an empty object. + const copy = structuredClone(args ?? {}) const prepared = tool.prepareArguments ? tool.prepareArguments(copy) : copy input = validateToolArguments(tool as never, { name, arguments: prepared } as never) as Args } catch (error) { @@ -488,8 +495,10 @@ export default definePlugin({ try { out = await tool.execute(id, input, signal, undefined, toolContext(signal, runId, id, depth + 1)) } catch (error) { - return { ...failed(message(error), input), durationMs: performance.now() - started } + // As pi: a throw is an error result, and it still goes through the tool_result handlers. + out = { content: [{ type: 'text', text: message(error) }], details: {}, isError: true } } + const durationMs = Math.round(performance.now() - started) const event = { type: 'tool_result', toolCallId: id, @@ -515,7 +524,7 @@ export default definePlugin({ toolCall: { id, name, arguments: input }, result: { content: event.content, details: event.details }, isError: event.isError, - durationMs: performance.now() - started, + durationMs, } } diff --git a/integrations/yoagent-rutis/plugins/yoagent.d.ts b/integrations/yoagent-rutis/plugins/yoagent.d.ts index 0798115..caba80c 100644 --- a/integrations/yoagent-rutis/plugins/yoagent.d.ts +++ b/integrations/yoagent-rutis/plugins/yoagent.d.ts @@ -43,8 +43,9 @@ export interface Yoagent { * Write a diagnostic to the host's logs (its `tracing` output, target * `yoagent_rutis::plugin`) rather than this process's stderr, which a * terminal or service host may not show. Levels `error`, `warn`, `info`, - * `debug`; messages over 8 KiB are cut. Fire-and-forget. Absent on hosts - * older than yoagent-rutis 0.2: fall back to `console.warn`. + * `debug`; messages over 8192 characters are cut. Fire-and-forget. On a + * host older than yoagent-rutis 0.2 rutis's stand-in for it throws: wrap + * the call (`try { yoagent.log(l, m).catch(f) } catch { f() }`). */ log?(level: 'error' | 'warn' | 'info' | 'debug', message: string): Promise } diff --git a/integrations/yoagent-rutis/src/languages.rs b/integrations/yoagent-rutis/src/languages.rs index 1a97855..c4614c2 100644 --- a/integrations/yoagent-rutis/src/languages.rs +++ b/integrations/yoagent-rutis/src/languages.rs @@ -157,7 +157,7 @@ impl HostDispatch for Service { } } -/// Longest message `log` writes; the rest is cut (a plugin cannot flood the host's logs). +/// Longest message `log` writes, in characters; the rest is cut (a plugin cannot flood the host's logs). const MAX_LOG_CHARS: usize = 8 * 1024; /// `log(level, message)`: a plugin's diagnostic in the host's `tracing` diff --git a/integrations/yoagent-rutis/tests/pi_test.rs b/integrations/yoagent-rutis/tests/pi_test.rs index 625f35b..a3cf436 100644 --- a/integrations/yoagent-rutis/tests/pi_test.rs +++ b/integrations/yoagent-rutis/tests/pi_test.rs @@ -524,6 +524,14 @@ async fn pi_semantics_on_the_less_common_paths() { text.contains("/3 bash error: Tool bash not found"), "{text}" ); + // A nested tool's throw goes through tool_result too (a handler scoped to + // pi_fail rewrote it). + assert!( + text.contains("/4 pi_fail error: pi_fail error seen"), + "{text}" + ); + // Called with no arguments: an empty object. + assert!(text.contains("/5 pi_dynamic ok: dynamic ok"), "{text}"); // terminate: true denied the call and stopped the run before its next request. assert!( result(16).2 && result(16).1.contains("stopping the run"), From d60f6ac88e2aa2a14dc93f2778502371cebe9ac2 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 22:54:20 +0200 Subject: [PATCH 14/15] fix(rutis): pi appendEntry and sendMessage are recorded, not refused pi app extensions record or show a tool's result through pi's session API inside the tool (pi-video-gen's appendEntry, pi-cavallo's sendMessage), after the paid work: refusing those calls failed the tool. They now go to the adapter's in-memory session (readable through ctx.sessionManager; a displayed message is also logged; a message never starts a turn). sendUserMessage and the rest still throw. Test: pi_render fixture tool. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- integrations/yoagent-rutis/README.md | 9 +++++-- .../yoagent-rutis/plugins/pi/fixture-extra.ts | 14 ++++++++++ .../plugins/pi/pi-extensions-adapter.ts | 27 +++++++++++++++---- integrations/yoagent-rutis/tests/pi_test.rs | 12 +++++++-- 6 files changed, 55 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe3e53b..7a75a9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ adheres to [Semantic Versioning](https://semver.org/). ### yoagent-rutis -- **pi extensions** (`plugins/pi/pi-extensions-adapter.ts`): the tools and tool policies of [pi](https://github.com/earendil-works/pi) coding-agent extensions, loaded unchanged with pi's own loader, as one yoagent handler following pi 1.1.0's semantics. `registerTool` → tools (pi's activation and first-registration-wins, `prepareArguments` and validation before the policies, only the judged arguments run), `setActiveTools` → an enforced allowlist, `tool_call` → `before_tool` (yoagent's built-ins under pi's names, arguments translated both ways, relative paths resolved where the policies look; an override of a built-in is enforced; `terminate` stops the run), `tool_result` → `after_tool` (a failing handler withholds the result), `input` → `on_input`, `before_agent_start` additions → a turn note. Fails closed: a load error, a failing `session_start`, an unfired deciding event (`context`, `message_end`, ...; unless `allowUnmapped`) or a pi tool named like a yoagent built-in (unless `withoutBuiltins`) refuses the load; other unmapped API (observer and session events, commands, renderers, providers, MCP servers) is reported, or refuses with `strict`. Example `pi_extensions` (any extension files; `--live` with DeepSeek; `--without`), test `pi_test`. `ctx.executeTool` runs pi tools as nested calls (pi's pipeline; never rejects); a deciding event registered mid-run stops that run; adapter warnings reach the host's logs. CI: a weekly `pi latest` job runs the adapter's tests against the newest pi. +- **pi extensions** (`plugins/pi/pi-extensions-adapter.ts`): the tools and tool policies of [pi](https://github.com/earendil-works/pi) coding-agent extensions, loaded unchanged with pi's own loader, as one yoagent handler following pi 1.1.0's semantics. `registerTool` → tools (pi's activation and first-registration-wins, `prepareArguments` and validation before the policies, only the judged arguments run), `setActiveTools` → an enforced allowlist, `tool_call` → `before_tool` (yoagent's built-ins under pi's names, arguments translated both ways, relative paths resolved where the policies look; an override of a built-in is enforced; `terminate` stops the run), `tool_result` → `after_tool` (a failing handler withholds the result), `input` → `on_input`, `before_agent_start` additions → a turn note. Fails closed: a load error, a failing `session_start`, an unfired deciding event (`context`, `message_end`, ...; unless `allowUnmapped`) or a pi tool named like a yoagent built-in (unless `withoutBuiltins`) refuses the load; other unmapped API (observer and session events, commands, renderers, providers, MCP servers) is reported, or refuses with `strict`. Example `pi_extensions` (any extension files; `--live` with DeepSeek; `--without`), test `pi_test`. `ctx.executeTool` runs pi tools as nested calls (pi's pipeline; never rejects); a deciding event registered mid-run stops that run; adapter warnings reach the host's logs. `pi.appendEntry` / `pi.sendMessage` are recorded in the adapter's in-memory session instead of throwing (pi tools that record their result this way no longer fail after the work). CI: a weekly `pi latest` job runs the adapter's tests against the newest pi. - **Images in tool results, across ecosystems.** TypeScript and Python handlers' `call_tool` results and `after_tool` edits take `content` blocks in yoagent's JSON shape (`{"type": "image", "data": , "mimeType"}` next to text blocks) instead of `text`, so pictures cross the bridge both ways (`after_tool` already saw `output.content`). The dsh adapter turns dsh image blocks (references into dsh's attachment store) into yoagent images through the `attachments` service when one is loaded; the rutis-agent example reads a runner's `{"content": [...]}` value as blocks (rutis-agent results are otherwise text). Rust handlers already returned full yoagent tool results. Tests: `content_blocks` unit test, TypeScript/Python round trip in `languages_test`, dsh with and without a store in `dsh_test`. - **Plugin logs reach the host.** The `yoagent` service gains `log(level, message)`: a TypeScript/Python plugin's diagnostics go to the host's `tracing` output (target `yoagent_rutis::plugin`) instead of the runtime process's stderr. The dsh and pi adapters use it. - Test fix: `dsh_test` no longer reads the abort file mid-write (empty) as the result. diff --git a/CLAUDE.md b/CLAUDE.md index 982c1bb..bd12a3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`), cut at `MAX_LOG_CHARS` 8192 characters; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`). `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.7.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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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. 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); `UNSUPPORTED_ACTIONS` (`sendMessage`, `appendEntry`, ...) 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`), cut at `MAX_LOG_CHARS` 8192 characters; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`). `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.7.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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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. 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.appendMessage` (a `custom` message; displayed 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index d578f5b..1195b4f 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -389,8 +389,13 @@ cannot go around it. 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. Other runtime actions (`pi.sendMessage`, `pi.appendEntry`, ...) -throw "not available in yoagent". There is no UI: `ctx.hasUI` is false and +servers. `pi.appendEntry` and `pi.sendMessage` are recorded in the +adapter's in-memory session — readable through `ctx.sessionManager`, a +displayed message also logged — so a tool that records or shows its result +this way (pi video tools do, after the paid work) does not fail; a message +never starts a turn or reaches the model (yoagent's history is written only +by its host). Other runtime actions (`pi.sendUserMessage`, `pi.setModel`, +...) throw "not available in yoagent". There is no UI: `ctx.hasUI` is false and `ctx.ui` behaves as in pi's print mode (`confirm` answers false), so a policy that would ask the user denies. Commands, dialogs and session history are planned as host-level plugin services — `ui` and `commands` diff --git a/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts index ac87010..06f7836 100644 --- a/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts +++ b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts @@ -90,6 +90,20 @@ export default function (pi: ExtensionAPI) { }, }) + // Records and shows its result through pi's session API, as pi video tools do. + pi.registerTool({ + name: 'pi_render', + label: 'Render', + description: 'Renders, then records the job.', + parameters: Type.Object({}), + async execute(_id, _params, _signal, _onUpdate, ctx) { + pi.appendEntry('render:last-job', { path: '/tmp/out.mp4' }) + pi.sendMessage({ customType: 'render_result', content: [{ type: 'text', text: 'Rendered /tmp/out.mp4' }], display: true }) + const entries = ctx.sessionManager.getEntries().filter((e: { type: string }) => e.type === 'custom').length + return { content: [{ type: 'text', text: `rendered; ${entries} custom entr${entries === 1 ? 'y' : 'ies'} recorded` }], details: undefined } + }, + }) + pi.on('tool_call', async (event) => { if (event.toolName === 'pi_echo' && event.input.text === 'boom') throw new Error('policy crashed') // yoagent's search, as pi's grep: include is pi's glob, and an unset diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts index 0aab532..9430fc6 100644 --- a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -94,9 +94,11 @@ // 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). Other runtime actions -// (`pi.sendMessage`, `pi.appendEntry`, ...) throw "not available in -// yoagent". `ctx.hasUI` is false and `ctx.ui` behaves as in +// go to the host's logs (`yoagent.log`, else stderr). `pi.appendEntry` and +// `pi.sendMessage` are recorded in the adapter's in-memory session (readable +// through `ctx.sessionManager`; a displayed message is also logged; a message +// never starts a turn); other runtime actions (`pi.sendUserMessage`, +// `pi.setModel`, ...) throw "not available in yoagent". `ctx.hasUI` is false and `ctx.ui` behaves as in // pi's print mode: `confirm` answers false, `select` and `input` nothing, so // a policy that would ask the user denies instead. @@ -204,9 +206,7 @@ const DECIDING_EVENTS = new Set([ /** pi runtime actions with no yoagent counterpart. */ const UNSUPPORTED_ACTIONS = [ - 'sendMessage', 'sendUserMessage', - 'appendEntry', 'setSessionName', 'getSessionName', 'setLabel', @@ -623,6 +623,23 @@ export default definePlugin({ })), ] } + // Session writes go to the adapter's in-memory session, so a tool that records or shows its + // result this way (pi video tools do, after the paid work) does not fail. They are pi state, + // read back through `ctx.sessionManager`; yoagent's own history is written only by its host. + runtime.appendEntry = (customType: string, data?: unknown) => { + sessionManager.appendCustomEntry(customType, data) + } + runtime.sendMessage = ( + message: { customType: string; content: string | Block[]; display?: boolean; details?: unknown }, + options?: { triggerTurn?: boolean; deliverAs?: string }, + ) => { + sessionManager.appendMessage({ role: 'custom', timestamp: Date.now(), display: true, ...message } as never) + const shown = typeof message.content === 'string' ? message.content : text(message.content) + if (message.display !== false) report('info', `[pi ${message.customType}] ${shown}`) + if (options?.triggerTurn || options?.deliverAs) { + report('warn', `[pi] ${message.customType}: a message cannot start or join a turn in yoagent; recorded only`) + } + } for (const action of UNSUPPORTED_ACTIONS) { runtime[action] = () => { throw new Error(`pi.${action}() is not available in yoagent`) diff --git a/integrations/yoagent-rutis/tests/pi_test.rs b/integrations/yoagent-rutis/tests/pi_test.rs index a3cf436..2b26cbd 100644 --- a/integrations/yoagent-rutis/tests/pi_test.rs +++ b/integrations/yoagent-rutis/tests/pi_test.rs @@ -391,6 +391,8 @@ async fn pi_semantics_on_the_less_common_paths() { ("write_file", json!({"path": "rel.txt", "content": "here"})), // Nested calls from a pi tool. ("pi_compose", json!({})), + // Session calls inside a tool: recorded, not failing the tool. + ("pi_render", json!({})), // Blocked with terminate: the run stops before its next request. ("bash", json!({"command": "echo stop-now"})), ]), @@ -532,9 +534,15 @@ async fn pi_semantics_on_the_less_common_paths() { ); // Called with no arguments: an empty object. assert!(text.contains("/5 pi_dynamic ok: dynamic ok"), "{text}"); + // appendEntry and sendMessage inside a tool are recorded in the adapter's session. + let (_, text, is_error) = result(16); + assert!( + !is_error && text == "rendered; 1 custom entry recorded", + "{results:?}" + ); // terminate: true denied the call and stopped the run before its next request. assert!( - result(16).2 && result(16).1.contains("stopping the run"), + result(17).2 && result(17).1.contains("stopping the run"), "{results:?}" ); assert_eq!(seen_now.len(), 1, "the run stopped: {seen_now:?}"); @@ -650,7 +658,7 @@ async fn what_could_leave_a_policy_unenforced_refuses_the_load() { ), ( "unsupported-start.ts", - "export default function (pi) { pi.on('session_start', () => pi.appendEntry('x', {})) }\n", + "export default function (pi) { pi.on('session_start', () => pi.sendUserMessage('x')) }\n", "session_start", ), ( From abad3a4c60b40400888213ecb1f019e4d096d8fd Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 22:56:52 +0200 Subject: [PATCH 15/15] fix(rutis): pi sendMessage stored as pi stores it (custom_message) Review: sendMessage wrote a 'message' entry; pi's runtime writes a custom_message entry (appendCustomMessageEntry), which extensions filter on when they read their messages back. Content defaults to [], display as given (pi's semantics), odd content cannot fail the tool. The test now reads back both the custom entry and the custom_message. README notes the in-memory session grows until the plugin reloads. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CLAUDE.md | 2 +- integrations/yoagent-rutis/README.md | 3 ++- .../yoagent-rutis/plugins/pi/fixture-extra.ts | 7 +++++-- .../yoagent-rutis/plugins/pi/pi-extensions-adapter.ts | 11 ++++++++--- integrations/yoagent-rutis/tests/pi_test.rs | 2 +- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bd12a3c..3195864 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### 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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`), cut at `MAX_LOG_CHARS` 8192 characters; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`). `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.7.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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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. 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.appendMessage` (a `custom` message; displayed 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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.6"` (0.x caret = 0.6.x; 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_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.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`), cut at `MAX_LOG_CHARS` 8192 characters; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`). `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.7.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); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `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` / `~/.dskey`; 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.7.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`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; 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. 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, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.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.7.0), `requirements.txt` (`rutis==0.7.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.7 (`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`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 1195b4f..61fba49 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -394,7 +394,8 @@ adapter's in-memory session — readable through `ctx.sessionManager`, a displayed message also logged — so a tool that records or shows its result this way (pi video tools do, after the paid work) does not fail; a message never starts a turn or reaches the model (yoagent's history is written only -by its host). Other runtime actions (`pi.sendUserMessage`, `pi.setModel`, +by its host). The session lives as long as the adapter: in a long-running +host it grows until the plugin reloads. Other runtime actions (`pi.sendUserMessage`, `pi.setModel`, ...) throw "not available in yoagent". There is no UI: `ctx.hasUI` is false and `ctx.ui` behaves as in pi's print mode (`confirm` answers false), so a policy that would ask the user denies. Commands, dialogs and session diff --git a/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts index 06f7836..a4e36e5 100644 --- a/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts +++ b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts @@ -99,8 +99,11 @@ export default function (pi: ExtensionAPI) { async execute(_id, _params, _signal, _onUpdate, ctx) { pi.appendEntry('render:last-job', { path: '/tmp/out.mp4' }) pi.sendMessage({ customType: 'render_result', content: [{ type: 'text', text: 'Rendered /tmp/out.mp4' }], display: true }) - const entries = ctx.sessionManager.getEntries().filter((e: { type: string }) => e.type === 'custom').length - return { content: [{ type: 'text', text: `rendered; ${entries} custom entr${entries === 1 ? 'y' : 'ies'} recorded` }], details: undefined } + // Read back the way pi stores them: a custom entry and a custom_message entry. + const all = ctx.sessionManager.getEntries() as { type: string; customType?: string }[] + const entry = all.filter((e) => e.type === 'custom' && e.customType === 'render:last-job').length + const message = all.filter((e) => e.type === 'custom_message' && e.customType === 'render_result').length + return { content: [{ type: 'text', text: `rendered; entries ${entry}, messages ${message}` }], details: undefined } }, }) diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts index 9430fc6..d0f1f49 100644 --- a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -633,9 +633,14 @@ export default definePlugin({ message: { customType: string; content: string | Block[]; display?: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: string }, ) => { - sessionManager.appendMessage({ role: 'custom', timestamp: Date.now(), display: true, ...message } as never) - const shown = typeof message.content === 'string' ? message.content : text(message.content) - if (message.display !== false) report('info', `[pi ${message.customType}] ${shown}`) + // As pi's own runtime stores it: a `custom_message` entry. + sessionManager.appendCustomMessageEntry(message.customType, message.content ?? [], message.display, message.details) + if (message.display) { + const content = message.content + const shown = + typeof content === 'string' ? content : Array.isArray(content) ? text(content) : JSON.stringify(content) + report('info', `[pi ${message.customType}] ${shown}`) + } if (options?.triggerTurn || options?.deliverAs) { report('warn', `[pi] ${message.customType}: a message cannot start or join a turn in yoagent; recorded only`) } diff --git a/integrations/yoagent-rutis/tests/pi_test.rs b/integrations/yoagent-rutis/tests/pi_test.rs index 2b26cbd..8218faa 100644 --- a/integrations/yoagent-rutis/tests/pi_test.rs +++ b/integrations/yoagent-rutis/tests/pi_test.rs @@ -537,7 +537,7 @@ async fn pi_semantics_on_the_less_common_paths() { // appendEntry and sendMessage inside a tool are recorded in the adapter's session. let (_, text, is_error) = result(16); assert!( - !is_error && text == "rendered; 1 custom entry recorded", + !is_error && text == "rendered; entries 1, messages 1", "{results:?}" ); // terminate: true denied the call and stopped the run before its next request.