From 877b111b41738123771625c44bc5b36f0ce230fc Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Mon, 12 Oct 2026 10:39:00 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(rutis):=20a=20`go`=20feature=20?= =?UTF-8?q?=E2=80=94=20Go=20plugins=20through=20the=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rutis 0.9 adds Go runtimes: a plugin built with the rutis Go SDK is a binary, one runtime each, started when a `go:` row needs it. A Go plugin reaches the `yoagent` host service like any other: - the handler is a map of Go functions (functions cross by reference); - the `signal` field of a hook's first argument decodes as nothing, so typed structs are unaffected; - when the host gives a call up (run cancel, hook timeout, unload), the Go call's context.Context is cancelled — the same contract as a JavaScript hook's AbortSignal. `go = ["rutis-bridge/go"]`; plugins/go/ is a working example (a policy denying `bash`, a slow policy watching ctx.Done(), a `go_echo` tool); tests/go_test.rs builds it with the local Go toolchain and checks the tool, the denial and the cancelled context through GoResolver + GoRuntimes. CI: setup-go 1.24 in the languages job; clippy/doc/test with `go`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .github/workflows/ci.yml | 16 +- integrations/yoagent-rutis/CLAUDE.md | 2 +- integrations/yoagent-rutis/Cargo.toml | 11 +- integrations/yoagent-rutis/README.md | 1 + integrations/yoagent-rutis/plugins/go/go.mod | 5 + integrations/yoagent-rutis/plugins/go/go.sum | 2 + integrations/yoagent-rutis/plugins/go/main.go | 74 +++++++++ integrations/yoagent-rutis/src/bridge.rs | 1 + integrations/yoagent-rutis/src/lib.rs | 4 +- integrations/yoagent-rutis/tests/go_test.rs | 157 ++++++++++++++++++ 10 files changed, 264 insertions(+), 9 deletions(-) create mode 100644 integrations/yoagent-rutis/plugins/go/go.mod create mode 100644 integrations/yoagent-rutis/plugins/go/go.sum create mode 100644 integrations/yoagent-rutis/plugins/go/main.go create mode 100644 integrations/yoagent-rutis/tests/go_test.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39c1159..3015ec7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -309,7 +309,7 @@ jobs: - name: Check run: cargo check --manifest-path "$MANIFEST" --all-targets - name: Check with the language features (rutis-bridge 0.9) - run: cargo check --manifest-path "$MANIFEST" --all-targets --features node,python,bun,websocket + run: cargo check --manifest-path "$MANIFEST" --all-targets --features node,python,bun,go,websocket # TypeScript (Node and Bun 1.4) and Python plugins through rutis-bridge 0.9, end to end: real # Node 22 (the floor: pi needs 22.19, rutis 0.9 22; the frontend job runs 24) @@ -318,7 +318,7 @@ jobs: # train (plugins/package-lock.json, plugins/requirements.txt). A missing # runtime fails the job instead of skipping (YOAGENT_RUTIS_REQUIRE_RUNTIMES). rutis-bridge-languages: - name: yoagent-rutis TypeScript / Bun / Python plugins + name: yoagent-rutis TypeScript / Bun / Python / Go plugins needs: changes if: needs.changes.outputs.rutis == 'true' runs-on: ubuntu-latest @@ -352,6 +352,12 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: "1.4" + # The Go plugin's test (tests/go_test.rs) builds plugins/go: the rutis + # Go SDK's floor. + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + cache-dependency-path: integrations/yoagent-rutis/plugins/go/go.sum - name: Install the rutis npm packages run: npm ci --prefix "$PLUGINS" # The dsh adapter's end-to-end test (tests/dsh_test.rs): dsh-tools, @@ -368,13 +374,13 @@ jobs: "$PLUGINS/.venv/bin/pip" install -r "$PLUGINS/requirements.txt" - name: Clippy (each language feature, and all) run: | - for features in node python bun websocket node,python,bun,websocket; do + for features in node python bun go websocket node,python,bun,go,websocket; do cargo clippy --manifest-path "$MANIFEST" --all-targets --features "$features" done - name: Documentation - run: RUSTDOCFLAGS="-Dwarnings" cargo doc --manifest-path "$MANIFEST" --no-deps --features node,python,bun,websocket + run: RUSTDOCFLAGS="-Dwarnings" cargo doc --manifest-path "$MANIFEST" --no-deps --features node,python,bun,go,websocket - name: Test - run: cargo test --manifest-path "$MANIFEST" --features node,python,bun,websocket + run: cargo test --manifest-path "$MANIFEST" --features node,python,bun,go,websocket - name: Example run: cargo run --manifest-path "$MANIFEST" --features node,python --example language_plugins - name: pi extensions example (scripted, the fixture extension) diff --git a/integrations/yoagent-rutis/CLAUDE.md b/integrations/yoagent-rutis/CLAUDE.md index 71770bd..0eece3c 100644 --- a/integrations/yoagent-rutis/CLAUDE.md +++ b/integrations/yoagent-rutis/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code when working in `integrations/yoagent-rutis/`. The repo ## rutis bridge (`integrations/yoagent-rutis/`, separate crate) -Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.9"` (0.x caret = 0.9.x; since 0.8 the packages in rutis's release train share one version — move the host and the npm/PyPI runtimes together: a 0.9 host sends call origins a 0.8 runtime rejects; rutis 0.9 needs Rust 1.88, so `rust-version` is 1.88 (yoagent's own stays 1.86); any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Bun / Python plugins** (`languages.rs`, features `node`/`bun`/`python`/`websocket` → optional `rutis-bridge = "0.9"`; `bun`: Bun 1.4+ runs the same TS plugins (`@arcships/rutis-bun` 0.9.1 in `plugins/package.json`, `LocalRuntime::bun(package, project).interpreter(exe)` + `RuntimeResolver::modules` → rows `bun:`); `languages_test` runs `every_hook` / `signal_aborts` on Node and on Bun (`bun_runtime()`: `YOAGENT_RUTIS_BUN` or `bun` on PATH, 1.4+; the rutis-loader dev-dependency enables `bun`); CI installs it with `oven-sh/setup-bun` 1.4, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.9 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (per image; rutis 0.9 closes a channel on any message over 16 MiB — local `MAX_MESSAGE` and websocket — ending the runtime, so: every host→plugin call goes through `fit` (`MAX_SEND` 15 MiB of serialized args; over → image blocks become a `[mime, N MB: omitted, too large to send to a plugin]` text block — `Oversize::OmitImagesInMessages` (hooks: only in arg 0's `messages`, never a call's `args`, which a policy may return as a rewrite) / `OmitImages` (`on_event`: the whole event) — still over → `ExtensionError` (deny / skip / tool fails); `after_tool` and `call_tool` use `call_exact` = `Oversize::Refuse` (after dropping `after_tool`'s duplicate `text`), so an oversized output is withheld and oversized args fail the tool, never altered; the over-limit warning is logged once per process, then debug; the pi adapter keys `message_end`'s seen set by `seenAs` (each image as `mime, N MB`, the host placeholder likewise), so a message is checked once whether it arrived whole or omitted; tested end to end in `languages_test` `a_conversation_over_the_message_limit…` (fails without the guard); plugin→host can't be guarded host-side: both adapters cap a result's images at `MAX_RESULT_IMAGE_CHARS` 12 MiB base64, the rest named in text), 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.9.1, 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]`; past `MAX_RESULT_IMAGE_CHARS` 12 MiB of base64 per result → `[…: left out, the result's images are too large together]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.9.1; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `info` report each (`reported` set; expected, not a fault — refusals stay `warn`; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.9.1 — 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.9.1), `requirements.txt` (`rutis==0.9.1`), `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 22.19+ / `plugins/node_modules` / Python 3.10+ with rutis 0.9 (`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.88, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 22 — the floor; pi needs 22.19, rutis 0.9 22 — 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.9"` (0.x caret = 0.9.x; since 0.8 the packages in rutis's release train share one version — move the host and the npm/PyPI runtimes together: a 0.9 host sends call origins a 0.8 runtime rejects; rutis 0.9 needs Rust 1.88, so `rust-version` is 1.88 (yoagent's own stays 1.86); any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Bun / Python plugins** (`languages.rs`, features `node`/`bun`/`python`/`websocket` → optional `rutis-bridge = "0.9"`; `bun`: Bun 1.4+ runs the same TS plugins (`@arcships/rutis-bun` 0.9.1 in `plugins/package.json`, `LocalRuntime::bun(package, project).interpreter(exe)` + `RuntimeResolver::modules` → rows `bun:`); `languages_test` runs `every_hook` / `signal_aborts` on Node and on Bun (`bun_runtime()`: `YOAGENT_RUTIS_BUN` or `bun` on PATH, 1.4+; the rutis-loader dev-dependency enables `bun`); CI installs it with `oven-sh/setup-bun` 1.4; `go`: Go plugins via the rutis Go SDK (`plugins/go/` — own `go.mod` (`go 1.24`, `github.com/arcships/rutis/go/rutis` v0.9.1) — registers `go-policy`: a `before_tool` denying `bash`, `slow_gate` waiting on its call `ctx.Done()`, a `go_echo` tool; the handler is a `map[string]any` of funcs (functions cross by reference; the `signal` field decodes as nothing; the host's cancel cancels the call's context)); `tests/go_test.rs` (`required-features = ["go"]`) builds it with `go build`, runs it through `GoResolver` + `GoRuntimes` (rows `go:go-policy`), checks tool, denial and that a policy timeout cancels Go's context (Go 1.24+ or `SKIPPED:`); CI `actions/setup-go` 1.24, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.9 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (per image; rutis 0.9 closes a channel on any message over 16 MiB — local `MAX_MESSAGE` and websocket — ending the runtime, so: every host→plugin call goes through `fit` (`MAX_SEND` 15 MiB of serialized args; over → image blocks become a `[mime, N MB: omitted, too large to send to a plugin]` text block — `Oversize::OmitImagesInMessages` (hooks: only in arg 0's `messages`, never a call's `args`, which a policy may return as a rewrite) / `OmitImages` (`on_event`: the whole event) — still over → `ExtensionError` (deny / skip / tool fails); `after_tool` and `call_tool` use `call_exact` = `Oversize::Refuse` (after dropping `after_tool`'s duplicate `text`), so an oversized output is withheld and oversized args fail the tool, never altered; the over-limit warning is logged once per process, then debug; the pi adapter keys `message_end`'s seen set by `seenAs` (each image as `mime, N MB`, the host placeholder likewise), so a message is checked once whether it arrived whole or omitted; tested end to end in `languages_test` `a_conversation_over_the_message_limit…` (fails without the guard); plugin→host can't be guarded host-side: both adapters cap a result's images at `MAX_RESULT_IMAGE_CHARS` 12 MiB base64, the rest named in text), 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.9.1, 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]`; past `MAX_RESULT_IMAGE_CHARS` 12 MiB of base64 per result → `[…: left out, the result's images are too large together]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.9.1; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `info` report each (`reported` set; expected, not a fault — refusals stay `warn`; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.9.1 — 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.9.1), `requirements.txt` (`rutis==0.9.1`), `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 22.19+ / `plugins/node_modules` / Python 3.10+ with rutis 0.9 (`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.88, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 22 — the floor; pi needs 22.19, rutis 0.9 22 — Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. ### Plugin compatibility matrix diff --git a/integrations/yoagent-rutis/Cargo.toml b/integrations/yoagent-rutis/Cargo.toml index 114489f..8fb8030 100644 --- a/integrations/yoagent-rutis/Cargo.toml +++ b/integrations/yoagent-rutis/Cargo.toml @@ -48,7 +48,7 @@ tokio-util = "0.7" tracing = "0.1" [package.metadata.docs.rs] -features = ["node", "python", "bun", "websocket"] +features = ["node", "python", "bun", "go", "websocket"] rustdoc-args = ["--cfg", "docsrs"] [features] @@ -57,6 +57,9 @@ rustdoc-args = ["--cfg", "docsrs"] default = [] # TypeScript / JavaScript plugins in local Node runtimes (Unix). node = ["rutis-bridge/node"] +# Go plugins (Go 1.24+, the rutis Go SDK): one binary per runtime, started +# when a row needs it (Unix). +go = ["rutis-bridge/go"] # Python plugins in local Python runtimes (Unix). python = ["rutis-bridge/python"] # TypeScript / JavaScript plugins in local Bun runtimes (Bun 1.4+, Unix). @@ -68,11 +71,15 @@ websocket = ["rutis-bridge/websocket"] tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] } # Loads language plugins as rows in the end-to-end tests. The bridge never # loads plugins itself: that is the host's job. -rutis-loader = { version = "0.9", features = ["node", "python", "bun"] } +rutis-loader = { version = "0.9", features = ["node", "python", "bun", "go"] } 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 = "go_test" +required-features = ["go"] + [[test]] name = "languages_test" required-features = ["node", "python"] diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 60b74ed..92b7a01 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -125,6 +125,7 @@ service `yoagent`: | `node` | TypeScript / JavaScript plugins in local Node runtimes (Node 22.19+, Linux/macOS) | | `bun` | the same TypeScript / JavaScript plugins in local Bun runtimes (Bun 1.4+, Linux/macOS): rows `bun:`, no build step; local only — rutis's Bun runtime cannot listen remotely yet | | `python` | Python plugins in local Python runtimes (Python 3.10+, Linux/macOS) | +| `go` | Go plugins built with the rutis Go SDK (Go 1.24+, Linux/macOS): each binary is a runtime, started when a `go:` row needs it (rutis-loader's `GoResolver` / `GoRuntimes`). A handler is a map of Go functions; a hook's `context.Context` is cancelled when the host gives the call up, as a JavaScript hook's `signal` aborts — see [`plugins/go/main.go`](plugins/go/main.go) | | `websocket` | runtimes and nodes on other machines, over `wss` | ```toml diff --git a/integrations/yoagent-rutis/plugins/go/go.mod b/integrations/yoagent-rutis/plugins/go/go.mod new file mode 100644 index 0000000..de969eb --- /dev/null +++ b/integrations/yoagent-rutis/plugins/go/go.mod @@ -0,0 +1,5 @@ +module github.com/yologdev/yoagent/integrations/yoagent-rutis/plugins/go + +go 1.24 + +require github.com/arcships/rutis/go/rutis v0.9.1 diff --git a/integrations/yoagent-rutis/plugins/go/go.sum b/integrations/yoagent-rutis/plugins/go/go.sum new file mode 100644 index 0000000..d5eeb6b --- /dev/null +++ b/integrations/yoagent-rutis/plugins/go/go.sum @@ -0,0 +1,2 @@ +github.com/arcships/rutis/go/rutis v0.9.1 h1:tYFxAyMMce2L2RnaLgatamRYgP3RRElG7A3v/1xcwgs= +github.com/arcships/rutis/go/rutis v0.9.1/go.mod h1:cJscMrYVCQ5AaQJjM39IPkk15A6GnBLfpo6XiZ9PT2M= diff --git a/integrations/yoagent-rutis/plugins/go/main.go b/integrations/yoagent-rutis/plugins/go/main.go new file mode 100644 index 0000000..5880c92 --- /dev/null +++ b/integrations/yoagent-rutis/plugins/go/main.go @@ -0,0 +1,74 @@ +// A yoagent handler written in Go: a policy, a tool, and a slow policy that +// watches its call's context (cancelled when the host gives the call up). +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/arcships/rutis/go/rutis" +) + +type Config struct { + Out string `json:"out"` +} + +func record(path, line string) { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err == nil { + fmt.Fprintln(f, line) + f.Close() + } +} + +var Plugin = rutis.Define(rutis.Plugin[Config]{ + Name: "go-policy", + Inject: []string{"yoagent"}, + Apply: func(ctx *rutis.Ctx, config Config) error { + var yoagent rutis.Service + if err := ctx.Use("yoagent", &yoagent); err != nil { + return err + } + handler := map[string]any{ + "before_tool": func(call context.Context, c map[string]any) (any, error) { + tool, _ := c["tool"].(string) + record(config.Out, "go before_tool: "+tool) + switch tool { + case "bash": + return map[string]any{"deny": "no shell from Go"}, nil + case "slow_gate": + select { + case <-call.Done(): + record(config.Out, "go slow_gate: cancelled") + return nil, call.Err() + case <-time.After(60 * time.Second): + return nil, nil + } + } + return nil, nil + }, + "tools": func(context.Context, map[string]any) (any, error) { + return []map[string]any{{"name": "go_echo", "description": "echoes from Go"}}, nil + }, + "call_tool": func(_ context.Context, c map[string]any) (any, error) { + return fmt.Sprintf("go says %v", c["args"]), nil + }, + } + var dispose *rutis.Function + if err := yoagent.Call(ctx, "register", &dispose, "go-policy", handler); err != nil { + return err + } + record(config.Out, "go registered") + ctx.Effect(func(c context.Context) error { + if dispose == nil { + return nil + } + return dispose.Call(c, nil) + }) + return nil + }, +}) + +func main() { rutis.Serve(Plugin) } diff --git a/integrations/yoagent-rutis/src/bridge.rs b/integrations/yoagent-rutis/src/bridge.rs index 4d7c716..501df45 100644 --- a/integrations/yoagent-rutis/src/bridge.rs +++ b/integrations/yoagent-rutis/src/bridge.rs @@ -48,6 +48,7 @@ impl RutisBridge { feature = "node", feature = "python", feature = "bun", + feature = "go", feature = "websocket" ))] crate::languages::provide(ctx, ®istry)?; diff --git a/integrations/yoagent-rutis/src/lib.rs b/integrations/yoagent-rutis/src/lib.rs index 9d52488..c39907e 100644 --- a/integrations/yoagent-rutis/src/lib.rs +++ b/integrations/yoagent-rutis/src/lib.rs @@ -87,7 +87,7 @@ //! //! # Plugins in other languages //! -//! With the `node`, `bun`, `python` or `websocket` feature (rutis-bridge 0.9), +//! With the `node`, `bun`, `python`, `go` or `websocket` feature (rutis-bridge 0.9), //! [`RutisBridge::install`] also provides the registry to plugins in other //! languages, as the host service `yoagent`. Such a plugin registers an //! object (or a Python dict) of async functions, with the same hooks and the @@ -151,6 +151,7 @@ mod host; feature = "node", feature = "python", feature = "bun", + feature = "go", feature = "websocket" ))] #[cfg_attr( @@ -159,6 +160,7 @@ mod host; feature = "node", feature = "python", feature = "bun", + feature = "go", feature = "websocket" ))) )] diff --git a/integrations/yoagent-rutis/tests/go_test.rs b/integrations/yoagent-rutis/tests/go_test.rs new file mode 100644 index 0000000..cb02f79 --- /dev/null +++ b/integrations/yoagent-rutis/tests/go_test.rs @@ -0,0 +1,157 @@ +//! Go plugins through the bridge: `plugins/go` (a policy, a tool, and a +//! slow policy that watches its call's context), built with the local Go +//! toolchain and run as a rutis Go runtime (`go:` rows). +//! +//! Needs Go 1.24+ on `PATH`; without it the test prints `SKIPPED:` and +//! passes, unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. +#![cfg(unix)] + +mod common; + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; + +use common::*; +use rutis::Ctx; +use rutis_loader::{ + Chain, GoBinaries, GoResolver, GoRuntimes, Layer, LoaderOptions, LoaderPlugin, Patch, + ServiceCatalog, +}; +use serde_json::json; +use yoagent_rutis::RutisBridge; + +/// The example plugin built into `dir`, if Go 1.24+ is there. +fn build(dir: &Path) -> Option { + let required = std::env::var_os("YOAGENT_RUTIS_REQUIRE_RUNTIMES").is_some_and(|v| v == "1"); + let skip = |why: String| { + assert!( + !required, + "Go is required (YOAGENT_RUTIS_REQUIRE_RUNTIMES=1): {why}" + ); + eprintln!("SKIPPED: Go unavailable: {why}"); + None + }; + let version = Command::new("go") + .args(["env", "GOVERSION"]) + .output() + .ok() + .filter(|out| out.status.success()) + .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string()); + let Some(version) = version else { + return skip("no `go` on PATH".into()); + }; + let numbers: Vec = version + .trim_start_matches("go") + .split('.') + .take(2) + .filter_map(|n| n.parse().ok()) + .collect(); + if numbers.len() < 2 || (numbers[0], numbers[1]) < (1, 24) { + return skip(format!("{version} found, 1.24+ needed")); + } + let binary = dir.join("go-policy"); + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins/go"); + let built = Command::new("go") + .arg("build") + .arg("-o") + .arg(&binary) + .arg(".") + .current_dir(&source) + .status() + .ok() + .filter(|status| status.success()); + match built { + Some(_) => Some(binary), + None => skip(format!("`go build` failed in {}", source.display())), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_go_plugin_offers_a_tool_denies_and_sees_its_call_cancelled() { + let dir = tempfile::tempdir().unwrap(); + let Some(binary) = build(dir.path()) else { + return; + }; + let out = dir.path().join("go.log"); + + let root = Ctx::root().unwrap(); + let bridge = RutisBridge::install(&root).unwrap(); + let mut catalog = ServiceCatalog::new(); + catalog.register_shared("yoagent"); + let go = Arc::new(GoResolver::new(GoBinaries::new().file(&binary)).with_catalog(&catalog)); + let plugin = LoaderPlugin::new( + Chain::new().with_shared(go.clone()), + LoaderOptions { + catalog, + ..LoaderOptions::default() + }, + ); + let loader = plugin.handle(); + root.plugin(plugin).await.unwrap(); + root.plugin(GoRuntimes::new(go, dir.path()).idle(None)) + .await + .unwrap(); + let rows = json!([{ "id": "go", "name": "go:go-policy", "config": { "out": out } }]); + let patches: Vec = serde_json::from_value(json!([{ "insert": rows }])).unwrap(); + let report = loader + .reconcile(vec![Layer::new("rows", patches)], None) + .await + .unwrap(); + assert!(report.failures.is_empty(), "{report:?}"); + let log = || std::fs::read_to_string(&out).unwrap_or_default(); + let registry = bridge.registry().clone(); + tokio::time::timeout(Duration::from_secs(60), async { + while !registry.handlers().iter().any(|h| h.name() == "go-policy") { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("the Go handler did not register: {}", log())); + + // A tool from Go, and a call Go denies. + let shell = Reply::new("bash", "ran"); + let shell_runs = shell.runs(); + let (first, _) = agent(vec![ + call("go_echo", json!({"say": "hi"})), + call("bash", json!({"command": "ls"})), + text("done"), + ]); + let mut first = first + .with_tools(vec![Box::new(shell)]) + .with_extension(bridge.extension()); + let (_, results) = run(&mut first, "go").await; + assert_eq!(results[0].0, "go_echo"); + assert!( + results[0].1.contains("go says") && results[0].1.contains("hi"), + "{results:?}" + ); + assert!( + results[1].2 && results[1].1.contains("no shell from Go"), + "{results:?}" + ); + assert_eq!(shell_runs.load(Ordering::SeqCst), 0); + + // A policy the host stops waiting for: Go's call context is cancelled. + let gate = Reply::new("slow_gate", "ran"); + let gate_runs = gate.runs(); + let (gated, _) = agent(vec![call("slow_gate", json!({})), text("done")]); + let mut gated = gated.with_tools(vec![Box::new(gate)]).with_extension( + bridge + .extension() + .with_policy_timeout(Some(Duration::from_millis(300))), + ); + let (_, results) = run(&mut gated, "gate").await; + assert!(results[0].2, "{results:?}"); + assert_eq!(gate_runs.load(Ordering::SeqCst), 0); + tokio::time::timeout(Duration::from_secs(10), async { + while !log().contains("go slow_gate: cancelled") { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("Go never saw the cancel: {}", log())); + root.shutdown().await.unwrap(); +} From eca3f165b660e191b6a2e2b1cbbae8ec8f73a0b8 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Mon, 12 Oct 2026 10:56:36 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(rutis=20go):=20review=20=E2=80=94=20fai?= =?UTF-8?q?l-closed=20and=20unload=20tests,=20a=20failed=20build=20fails,?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - go_test: a failed `go build` with Go present fails the test (it was a skip); covers an after_tool redaction from Go, Go's denial / returned error / malformed answer each denying, the timeout reason, a run cancel cancelling the Go call's context, and unloading the row removing the handler (the disposer Effect), after which `bash` runs. - plugins/go: `record` reports its own errors on stderr; the doc says a hook's context also ends when the hook returns (the SDK's defer cancel), so watch it only while the call runs; register's missing disposer is an error. - Docs: languages.rs, lib.rs, README handler shapes include Go; CLAUDE.md "decodes as nil"; CI comment. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .github/workflows/ci.yml | 2 +- integrations/yoagent-rutis/CLAUDE.md | 2 +- integrations/yoagent-rutis/README.md | 3 +- integrations/yoagent-rutis/plugins/go/main.go | 66 +++++++-- integrations/yoagent-rutis/src/languages.rs | 6 +- integrations/yoagent-rutis/src/lib.rs | 4 +- integrations/yoagent-rutis/tests/go_test.rs | 130 ++++++++++++++---- 7 files changed, 167 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3015ec7..0d168f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -311,7 +311,7 @@ jobs: - name: Check with the language features (rutis-bridge 0.9) run: cargo check --manifest-path "$MANIFEST" --all-targets --features node,python,bun,go,websocket - # TypeScript (Node and Bun 1.4) and Python plugins through rutis-bridge 0.9, end to end: real + # TypeScript (Node and Bun 1.4), Python and Go (1.24) plugins through rutis-bridge 0.9, end to end: real # Node 22 (the floor: pi needs 22.19, rutis 0.9 22; the frontend job runs 24) # and Python 3.12 runtimes load the example plugins and fixtures as # rutis-loader rows. The npm and PyPI packages are pinned to the 0.9.1 diff --git a/integrations/yoagent-rutis/CLAUDE.md b/integrations/yoagent-rutis/CLAUDE.md index 0eece3c..f7d69b3 100644 --- a/integrations/yoagent-rutis/CLAUDE.md +++ b/integrations/yoagent-rutis/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code when working in `integrations/yoagent-rutis/`. The repo ## rutis bridge (`integrations/yoagent-rutis/`, separate crate) -Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.9"` (0.x caret = 0.9.x; since 0.8 the packages in rutis's release train share one version — move the host and the npm/PyPI runtimes together: a 0.9 host sends call origins a 0.8 runtime rejects; rutis 0.9 needs Rust 1.88, so `rust-version` is 1.88 (yoagent's own stays 1.86); any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Bun / Python plugins** (`languages.rs`, features `node`/`bun`/`python`/`websocket` → optional `rutis-bridge = "0.9"`; `bun`: Bun 1.4+ runs the same TS plugins (`@arcships/rutis-bun` 0.9.1 in `plugins/package.json`, `LocalRuntime::bun(package, project).interpreter(exe)` + `RuntimeResolver::modules` → rows `bun:`); `languages_test` runs `every_hook` / `signal_aborts` on Node and on Bun (`bun_runtime()`: `YOAGENT_RUTIS_BUN` or `bun` on PATH, 1.4+; the rutis-loader dev-dependency enables `bun`); CI installs it with `oven-sh/setup-bun` 1.4; `go`: Go plugins via the rutis Go SDK (`plugins/go/` — own `go.mod` (`go 1.24`, `github.com/arcships/rutis/go/rutis` v0.9.1) — registers `go-policy`: a `before_tool` denying `bash`, `slow_gate` waiting on its call `ctx.Done()`, a `go_echo` tool; the handler is a `map[string]any` of funcs (functions cross by reference; the `signal` field decodes as nothing; the host's cancel cancels the call's context)); `tests/go_test.rs` (`required-features = ["go"]`) builds it with `go build`, runs it through `GoResolver` + `GoRuntimes` (rows `go:go-policy`), checks tool, denial and that a policy timeout cancels Go's context (Go 1.24+ or `SKIPPED:`); CI `actions/setup-go` 1.24, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.9 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (per image; rutis 0.9 closes a channel on any message over 16 MiB — local `MAX_MESSAGE` and websocket — ending the runtime, so: every host→plugin call goes through `fit` (`MAX_SEND` 15 MiB of serialized args; over → image blocks become a `[mime, N MB: omitted, too large to send to a plugin]` text block — `Oversize::OmitImagesInMessages` (hooks: only in arg 0's `messages`, never a call's `args`, which a policy may return as a rewrite) / `OmitImages` (`on_event`: the whole event) — still over → `ExtensionError` (deny / skip / tool fails); `after_tool` and `call_tool` use `call_exact` = `Oversize::Refuse` (after dropping `after_tool`'s duplicate `text`), so an oversized output is withheld and oversized args fail the tool, never altered; the over-limit warning is logged once per process, then debug; the pi adapter keys `message_end`'s seen set by `seenAs` (each image as `mime, N MB`, the host placeholder likewise), so a message is checked once whether it arrived whole or omitted; tested end to end in `languages_test` `a_conversation_over_the_message_limit…` (fails without the guard); plugin→host can't be guarded host-side: both adapters cap a result's images at `MAX_RESULT_IMAGE_CHARS` 12 MiB base64, the rest named in text), 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.9.1, 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]`; past `MAX_RESULT_IMAGE_CHARS` 12 MiB of base64 per result → `[…: left out, the result's images are too large together]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.9.1; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `info` report each (`reported` set; expected, not a fault — refusals stay `warn`; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.9.1 — 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.9.1), `requirements.txt` (`rutis==0.9.1`), `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 22.19+ / `plugins/node_modules` / Python 3.10+ with rutis 0.9 (`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.88, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 22 — the floor; pi needs 22.19, rutis 0.9 22 — 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.9"` (0.x caret = 0.9.x; since 0.8 the packages in rutis's release train share one version — move the host and the npm/PyPI runtimes together: a 0.9 host sends call origins a 0.8 runtime rejects; rutis 0.9 needs Rust 1.88, so `rust-version` is 1.88 (yoagent's own stays 1.86); any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Bun / Python plugins** (`languages.rs`, features `node`/`bun`/`python`/`websocket` → optional `rutis-bridge = "0.9"`; `bun`: Bun 1.4+ runs the same TS plugins (`@arcships/rutis-bun` 0.9.1 in `plugins/package.json`, `LocalRuntime::bun(package, project).interpreter(exe)` + `RuntimeResolver::modules` → rows `bun:`); `languages_test` runs `every_hook` / `signal_aborts` on Node and on Bun (`bun_runtime()`: `YOAGENT_RUTIS_BUN` or `bun` on PATH, 1.4+; the rutis-loader dev-dependency enables `bun`); CI installs it with `oven-sh/setup-bun` 1.4; `go`: Go plugins via the rutis Go SDK (`plugins/go/` — own `go.mod` (`go 1.24`, `github.com/arcships/rutis/go/rutis` v0.9.1) — registers `go-policy`: a `before_tool` denying `bash`, `slow_gate` waiting on its call `ctx.Done()`, a `go_echo` tool; the handler is a `map[string]any` of funcs (functions cross by reference; the `signal` field decodes as nil; the call's context also ends when the hook returns (Go's `defer cancel`), so "never cancelled after completing" is not observable from Go; the host's cancel cancels the call's context)); `tests/go_test.rs` (`required-features = ["go"]`) builds it with `go build`, runs it through `GoResolver` + `GoRuntimes` (rows `go:go-policy`), checks the tool + an `after_tool` redaction, that Go's denial / returned error / malformed answer each deny, that a policy timeout and a run cancel cancel Go's context, and that unloading the row removes the handler (disposer `Effect`), after which `bash` runs; a failed `go build` with Go present fails, never skips (Go 1.24+ or `SKIPPED:`); CI `actions/setup-go` 1.24, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.9 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (per image; rutis 0.9 closes a channel on any message over 16 MiB — local `MAX_MESSAGE` and websocket — ending the runtime, so: every host→plugin call goes through `fit` (`MAX_SEND` 15 MiB of serialized args; over → image blocks become a `[mime, N MB: omitted, too large to send to a plugin]` text block — `Oversize::OmitImagesInMessages` (hooks: only in arg 0's `messages`, never a call's `args`, which a policy may return as a rewrite) / `OmitImages` (`on_event`: the whole event) — still over → `ExtensionError` (deny / skip / tool fails); `after_tool` and `call_tool` use `call_exact` = `Oversize::Refuse` (after dropping `after_tool`'s duplicate `text`), so an oversized output is withheld and oversized args fail the tool, never altered; the over-limit warning is logged once per process, then debug; the pi adapter keys `message_end`'s seen set by `seenAs` (each image as `mime, N MB`, the host placeholder likewise), so a message is checked once whether it arrived whole or omitted; tested end to end in `languages_test` `a_conversation_over_the_message_limit…` (fails without the guard); plugin→host can't be guarded host-side: both adapters cap a result's images at `MAX_RESULT_IMAGE_CHARS` 12 MiB base64, the rest named in text), 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.9.1, 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]`; past `MAX_RESULT_IMAGE_CHARS` 12 MiB of base64 per result → `[…: left out, the result's images are too large together]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.9.1; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `info` report each (`reported` set; expected, not a fault — refusals stay `warn`; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.9.1 — 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.9.1), `requirements.txt` (`rutis==0.9.1`), `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 22.19+ / `plugins/node_modules` / Python 3.10+ with rutis 0.9 (`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.88, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 22 — the floor; pi needs 22.19, rutis 0.9 22 — Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. ### Plugin compatibility matrix diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 92b7a01..da8b4bf 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -133,7 +133,8 @@ yoagent-rutis = { git = "https://github.com/yologdev/yoagent", features = ["node ``` A plugin injects `yoagent` and registers a handler: an object (in Python, a -class instance or a dict) of async functions, passed by reference — rutis +class instance or a dict; in Go, a map of functions — see +[`plugins/go/main.go`](plugins/go/main.go)) of async functions, passed by reference — rutis calls them in the plugin's own process. `register(name, handler, options?)` returns a function that unregisters it; pass it to `ctx.effect` so the handler goes when the plugin unloads. diff --git a/integrations/yoagent-rutis/plugins/go/main.go b/integrations/yoagent-rutis/plugins/go/main.go index 5880c92..57ff76f 100644 --- a/integrations/yoagent-rutis/plugins/go/main.go +++ b/integrations/yoagent-rutis/plugins/go/main.go @@ -1,11 +1,26 @@ -// A yoagent handler written in Go: a policy, a tool, and a slow policy that -// watches its call's context (cancelled when the host gives the call up). +// A yoagent handler written in Go, with the rutis Go SDK. +// +// It registers a handler — a map of Go functions — with the `yoagent` host +// service: a policy (`before_tool`), a redaction (`after_tool`) and tools +// (`tools`, `call_tool`). Each hook gets one argument, an object decoded +// into a map; its `signal` field decodes as nil: the host's cancel arrives +// as the hook's context.Context instead, cancelled when the host gives the +// call up (the run cancelled, a hook timed out, the plugin unloaded). As Go +// contexts do, it also ends when the hook returns: watch it only while the +// call runs. +// Returning an error fails closed, as a throw does in TypeScript: a policy +// denies, a redaction withholds, a tool call fails. +// +// tests/go_test.rs builds and runs it; `config.out` is a file it writes +// what happened to. package main import ( "context" + "errors" "fmt" "os" + "strings" "time" "github.com/arcships/rutis/go/rutis" @@ -15,11 +30,18 @@ type Config struct { Out string `json:"out"` } +// record appends `line` to the file `path`; a failure goes to stderr, which +// reaches the host's. func record(path, line string) { f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err == nil { - fmt.Fprintln(f, line) - f.Close() + _, err = fmt.Fprintln(f, line) + if closeErr := f.Close(); err == nil { + err = closeErr + } + } + if err != nil { + fmt.Fprintf(os.Stderr, "go-policy: record %q to %s: %v\n", line, path, err) } } @@ -38,6 +60,10 @@ var Plugin = rutis.Define(rutis.Plugin[Config]{ switch tool { case "bash": return map[string]any{"deny": "no shell from Go"}, nil + case "fail_gate": + return nil, errors.New("go policy backend down") + case "odd_gate": + return map[string]any{"allow": "maybe"}, nil case "slow_gate": select { case <-call.Done(): @@ -49,10 +75,27 @@ var Plugin = rutis.Define(rutis.Plugin[Config]{ } return nil, nil }, + "after_tool": func(_ context.Context, c map[string]any, output map[string]any) (any, error) { + if c["tool"] == "go_echo" { + text, _ := output["text"].(string) + return map[string]any{"text": strings.ReplaceAll(text, "secret", "[go redacted]")}, nil + } + return nil, nil + }, "tools": func(context.Context, map[string]any) (any, error) { - return []map[string]any{{"name": "go_echo", "description": "echoes from Go"}}, nil + return []map[string]any{ + {"name": "go_echo", "description": "echoes its arguments from Go"}, + {"name": "go_wait", "description": "waits until cancelled"}, + }, nil }, - "call_tool": func(_ context.Context, c map[string]any) (any, error) { + "call_tool": func(call context.Context, c map[string]any) (any, error) { + switch c["tool"] { + case "go_wait": + record(config.Out, "go wait: started") + <-call.Done() + record(config.Out, "go wait: cancelled") + return nil, call.Err() + } return fmt.Sprintf("go says %v", c["args"]), nil }, } @@ -60,13 +103,12 @@ var Plugin = rutis.Define(rutis.Plugin[Config]{ if err := yoagent.Call(ctx, "register", &dispose, "go-policy", handler); err != nil { return err } + if dispose == nil { + return errors.New("register returned no disposer") + } record(config.Out, "go registered") - ctx.Effect(func(c context.Context) error { - if dispose == nil { - return nil - } - return dispose.Call(c, nil) - }) + // The handler goes when the plugin unloads. + ctx.Effect(func(c context.Context) error { return dispose.Call(c, nil) }) return nil }, }) diff --git a/integrations/yoagent-rutis/src/languages.rs b/integrations/yoagent-rutis/src/languages.rs index 0fcc431..bacf4fb 100644 --- a/integrations/yoagent-rutis/src/languages.rs +++ b/integrations/yoagent-rutis/src/languages.rs @@ -1,6 +1,6 @@ -//! Plugins in other languages — TypeScript / JavaScript on Node or Bun, and -//! Python — through [rutis-bridge](https://docs.rs/rutis-bridge) 0.9 (the -//! `node`, `bun`, `python` and `websocket` features). +//! Plugins in other languages — TypeScript / JavaScript on Node or Bun, +//! Python and Go — through [rutis-bridge](https://docs.rs/rutis-bridge) 0.9 +//! (the `node`, `bun`, `python`, `go` and `websocket` features). //! //! With one of those features, [`RutisBridge::install`](crate::RutisBridge::install) //! also provides the registry to language plugins as the host service diff --git a/integrations/yoagent-rutis/src/lib.rs b/integrations/yoagent-rutis/src/lib.rs index c39907e..2f2b6d3 100644 --- a/integrations/yoagent-rutis/src/lib.rs +++ b/integrations/yoagent-rutis/src/lib.rs @@ -26,7 +26,7 @@ //! yoagent itself knows nothing about rutis; the bridge uses only yoagent's //! public API. //! -//! TypeScript and Python handlers (features `node`, `bun`, `python`, `websocket`) +//! TypeScript, Python and Go handlers (features `node`, `bun`, `python`, `go`, `websocket`) //! can return images in tool results and write logs into the host's //! `tracing` output (target `yoagent_rutis::plugin`). Plugins from other //! agent ecosystems — DSH tool plugins, pi extensions — plug in through @@ -90,7 +90,7 @@ //! With the `node`, `bun`, `python`, `go` or `websocket` feature (rutis-bridge 0.9), //! [`RutisBridge::install`] also provides the registry to plugins in other //! languages, as the host service `yoagent`. Such a plugin registers an -//! object (or a Python dict) of async functions, with the same hooks and the +//! object (a Python dict, a Go map of functions) of async functions, with the same hooks and the //! same plain-JSON arguments as a Rust [`Handler`]: //! //! ```ts diff --git a/integrations/yoagent-rutis/tests/go_test.rs b/integrations/yoagent-rutis/tests/go_test.rs index cb02f79..efe0f6b 100644 --- a/integrations/yoagent-rutis/tests/go_test.rs +++ b/integrations/yoagent-rutis/tests/go_test.rs @@ -1,9 +1,10 @@ -//! Go plugins through the bridge: `plugins/go` (a policy, a tool, and a -//! slow policy that watches its call's context), built with the local Go -//! toolchain and run as a rutis Go runtime (`go:` rows). +//! Go plugins through the bridge: `plugins/go` (policies, a redaction and +//! tools; see its doc), built with the local Go toolchain and run as a rutis +//! Go runtime (`go:` rows). //! //! Needs Go 1.24+ on `PATH`; without it the test prints `SKIPPED:` and -//! passes, unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. +//! passes, unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. With Go there, a +//! plugin that does not build fails the test. #![cfg(unix)] mod common; @@ -54,6 +55,7 @@ fn build(dir: &Path) -> Option { } let binary = dir.join("go-policy"); let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins/go"); + // Go is there: a plugin that does not build is a failure, never a skip. let built = Command::new("go") .arg("build") .arg("-o") @@ -61,16 +63,13 @@ fn build(dir: &Path) -> Option { .arg(".") .current_dir(&source) .status() - .ok() - .filter(|status| status.success()); - match built { - Some(_) => Some(binary), - None => skip(format!("`go build` failed in {}", source.display())), - } + .expect("run `go build`"); + assert!(built.success(), "`go build` failed in {}", source.display()); + Some(binary) } #[tokio::test(flavor = "multi_thread")] -async fn a_go_plugin_offers_a_tool_denies_and_sees_its_call_cancelled() { +async fn a_go_plugin_offers_tools_redacts_fails_closed_and_unloads() { let dir = tempfile::tempdir().unwrap(); let Some(binary) = build(dir.path()) else { return; @@ -111,30 +110,65 @@ async fn a_go_plugin_offers_a_tool_denies_and_sees_its_call_cancelled() { .await .unwrap_or_else(|_| panic!("the Go handler did not register: {}", log())); - // A tool from Go, and a call Go denies. - let shell = Reply::new("bash", "ran"); - let shell_runs = shell.runs(); + let until = |line: &'static str| { + let log = &log; + async move { + tokio::time::timeout(Duration::from_secs(10), async { + while !log().contains(line) { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("Go never logged {line:?}: {}", log())); + } + }; + let blocked: Vec = ["bash", "fail_gate", "odd_gate"] + .iter() + .map(|name| Reply::new(name, "ran")) + .collect(); + let blocked_runs: Vec<_> = blocked.iter().map(Reply::runs).collect(); + + // A tool from Go, whose output Go's after_tool redacts; and policies: + // Go's denial, Go's error and a malformed answer all deny. let (first, _) = agent(vec![ - call("go_echo", json!({"say": "hi"})), - call("bash", json!({"command": "ls"})), + calls(&[ + ("go_echo", json!({"say": "a secret"})), + ("bash", json!({"command": "ls"})), + ("fail_gate", json!({})), + ("odd_gate", json!({})), + ]), text("done"), ]); let mut first = first - .with_tools(vec![Box::new(shell)]) + .with_tools( + blocked + .into_iter() + .map(|t| Box::new(t) as Box) + .collect(), + ) .with_extension(bridge.extension()); let (_, results) = run(&mut first, "go").await; - assert_eq!(results[0].0, "go_echo"); + let result = |name: &str| results.iter().find(|(n, ..)| n == name).cloned().unwrap(); + let (_, echoed, failed) = result("go_echo"); assert!( - results[0].1.contains("go says") && results[0].1.contains("hi"), + !failed && echoed.contains("go says") && echoed.contains("a [go redacted]"), "{results:?}" ); + assert!(!echoed.contains("secret"), "{echoed}"); + for (name, reason) in [ + ("bash", "no shell from Go"), + ("fail_gate", "go policy backend down"), + ("odd_gate", "unexpected value"), + ] { + let (_, text, denied) = result(name); + assert!(denied && text.contains(reason), "{name}: {results:?}"); + } assert!( - results[1].2 && results[1].1.contains("no shell from Go"), + blocked_runs.iter().all(|r| r.load(Ordering::SeqCst) == 0), "{results:?}" ); - assert_eq!(shell_runs.load(Ordering::SeqCst), 0); - // A policy the host stops waiting for: Go's call context is cancelled. + // A policy the host stops waiting for: denied, and Go's context cancelled. let gate = Reply::new("slow_gate", "ran"); let gate_runs = gate.runs(); let (gated, _) = agent(vec![call("slow_gate", json!({})), text("done")]); @@ -144,14 +178,58 @@ async fn a_go_plugin_offers_a_tool_denies_and_sees_its_call_cancelled() { .with_policy_timeout(Some(Duration::from_millis(300))), ); let (_, results) = run(&mut gated, "gate").await; - assert!(results[0].2, "{results:?}"); + assert!( + results[0].2 && results[0].1.contains("did not answer"), + "{results:?}" + ); assert_eq!(gate_runs.load(Ordering::SeqCst), 0); - tokio::time::timeout(Duration::from_secs(10), async { - while !log().contains("go slow_gate: cancelled") { + until("go slow_gate: cancelled").await; + + // Cancelling the run cancels the Go tool call in flight. + let (waiting, _) = agent(vec![call("go_wait", json!({})), text("never sent")]); + let mut waiting = waiting.with_extension(bridge.extension()); + let mut events = waiting.prompt("wait").await; + until("go wait: started").await; + waiting.abort(); + tokio::time::timeout(Duration::from_secs(30), async { + while events.recv().await.is_some() {} + }) + .await + .expect("the cancelled run ends"); + waiting.finish().await; + until("go wait: cancelled").await; + + // Unloading the plugin removes its handler (its disposer ran): its tools + // are gone and its policy no longer denies. + loader.reconcile(vec![], None).await.unwrap(); + tokio::time::timeout(Duration::from_secs(30), async { + while registry.handlers().iter().any(|h| h.name() == "go-policy") { tokio::time::sleep(Duration::from_millis(20)).await; } }) .await - .unwrap_or_else(|_| panic!("Go never saw the cancel: {}", log())); + .expect("the Go handler is removed on unload"); + let shell = Reply::new("bash", "ran"); + let shell_runs = shell.runs(); + let (after, _) = agent(vec![call("bash", json!({"command": "ls"})), text("done")]); + let mut after = after + .with_tools(vec![Box::new(shell)]) + .with_extension(bridge.extension()); + run(&mut after, "after").await; + assert_eq!(shell_runs.load(Ordering::SeqCst), 1, "no Go policy left"); root.shutdown().await.unwrap(); } + +/// One response calling several tools at once. +fn calls(names: &[(&str, serde_json::Value)]) -> yoagent::provider::mock::MockResponse { + yoagent::provider::mock::MockResponse::ToolCalls( + names + .iter() + .map(|(name, args)| yoagent::provider::mock::MockToolCall { + provider_metadata: None, + name: (*name).into(), + arguments: args.clone(), + }) + .collect(), + ) +}