From 1ca3b888acf6cfffbe754aeb2cf352536ef3998d Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 23:05:53 +0200 Subject: [PATCH 1/2] feat(rutis): plugin logs to the host, the adapter contract, one contract three ecosystems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bridge: the yoagent service gains log(level, message, {run_id}?) → tracing target yoagent_rutis::plugin with a run_id field; the dsh adapter uses it. Test: tests/plugin_log_test.rs (own binary) - docs: the extensions guide shows DSH, rutis-agent and pi plugging in through Extension + the bridge with no core change, and what the bridge gives every ecosystem (images, logs, fail-closed); the bridge README states the adapter contract (seven fail-closed rules) - CI: the rutis-bridge job runs the rutis-agent example - guide: yoagent-rutis is on crates.io Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .github/workflows/ci.yml | 11 +- CHANGELOG.md | 2 + CLAUDE.md | 2 +- docs/concepts/extensions.md | 16 +- integrations/yoagent-rutis/Cargo.toml | 6 + integrations/yoagent-rutis/README.md | 45 +++++- .../plugins/dsh/dsh-tools-adapter.ts | 8 +- .../yoagent-rutis/plugins/yoagent.d.ts | 11 ++ integrations/yoagent-rutis/src/languages.rs | 55 ++++++- .../yoagent-rutis/tests/plugin_log_test.rs | 145 ++++++++++++++++++ 10 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 integrations/yoagent-rutis/tests/plugin_log_test.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28a70d9..dc91842 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -213,7 +213,7 @@ jobs: rutis-bridge: name: yoagent-rutis bridge runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 env: MANIFEST: integrations/yoagent-rutis/Cargo.toml steps: @@ -223,7 +223,9 @@ jobs: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 with: - workspaces: integrations/yoagent-rutis + workspaces: | + integrations/yoagent-rutis + integrations/yoagent-rutis/examples/rutis-agent-tools - name: Format run: cargo fmt --manifest-path "$MANIFEST" -- --check - name: Clippy @@ -234,6 +236,11 @@ jobs: run: cargo test --manifest-path "$MANIFEST" - name: Example run: cargo run --manifest-path "$MANIFEST" --example policy_plugin + # rutis-agent's tools through the bridge (its own workspace; rutis-agent + # from git at rutis v0.7.0): one of the ecosystems the extensions guide + # names, kept working here. Scripted, no network beyond the git fetch. + - name: rutis-agent example + run: cargo run --manifest-path integrations/yoagent-rutis/examples/rutis-agent-tools/Cargo.toml # The bridge declares rust-version 1.86 (yoagent's MSRV; rutis 0.6.1 and # rutis-bridge 0.7 declare 1.85). Mirrors the `msrv` job above, including its diff --git a/CHANGELOG.md b/CHANGELOG.md index 1179422..c3be9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ adheres to [Semantic Versioning](https://semver.org/). ### yoagent-rutis - **Images in tool results, across ecosystems.** TypeScript and Python handlers' `call_tool` results and `after_tool` edits take `content` blocks in yoagent's JSON shape (`{"type": "image", "data": , "mimeType"}` next to text blocks) instead of `text`, so pictures cross the bridge both ways (`after_tool` already saw `output.content`). The dsh adapter turns dsh image blocks (references into dsh's attachment store) into yoagent images through the `attachments` service when one is loaded; the rutis-agent example reads a runner's `{"content": [...]}` value as blocks (rutis-agent results are otherwise text). Rust handlers already returned full yoagent tool results. Tests: `content_blocks` unit test, TypeScript/Python round trip in `languages_test`, dsh with and without a store in `dsh_test`. +- **Plugin logs reach the host.** The `yoagent` service gains `log(level, message, { run_id }?)`: a TypeScript/Python plugin's diagnostics go to the host's `tracing` output (target `yoagent_rutis::plugin`, with the run as a `run_id` field) instead of the runtime process's stderr. The dsh adapter uses it. Test: `tests/plugin_log_test.rs`. +- **Docs: one contract, other ecosystems.** The extensions guide shows DSH, rutis-agent and pi plugging in through `Extension` and the bridge with no core change; the bridge README states the adapter contract (map what the host honours, refuse what would decide unenforced, deny on failing policies, withhold on failing redactions, report what is ignored). CI now also runs the rutis-agent example. - Test fix: `dsh_test` no longer reads the abort file mid-write (empty) as the result. ## 0.25.0 (2026-10-08) diff --git a/CLAUDE.md b/CLAUDE.md index cfd9083..e022afb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### rutis bridge (`integrations/yoagent-rutis/`, separate crate) -Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.6"` (0.x caret = 0.6.x; any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Python plugins** (`languages.rs`, features `node`/`python`/`websocket` → optional `rutis-bridge = "0.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (websocket frame limit 16 MiB), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7 allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.7.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY` / `~/.dskey`; self-checks). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.7.0), `requirements.txt` (`rutis==0.7.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 24 / `plugins/node_modules` / Python with rutis 0.7 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.86, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 24, Python 3.12: `npm ci` in `plugins/` and `plugins/dsh/`, clippy per feature, docs, tests with the runtimes required, the `language_plugins` example; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. +Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.6"` (0.x caret = 0.6.x; any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Python plugins** (`languages.rs`, features `node`/`python`/`websocket` → optional `rutis-bridge = "0.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, 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` (`LogContext`, deny unknown fields; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (websocket frame limit 16 MiB), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7 allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.7.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY` / `~/.dskey`; self-checks). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.7.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.7.0), `requirements.txt` (`rutis==0.7.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 24 / `plugins/node_modules` / Python with rutis 0.7 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.86, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 24, Python 3.12: `npm ci` in `plugins/` and `plugins/dsh/`, clippy per feature, docs, tests with the runtimes required, the `language_plugins` example; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/docs/concepts/extensions.md b/docs/concepts/extensions.md index bd8ea4e..5f5e24c 100644 --- a/docs/concepts/extensions.md +++ b/docs/concepts/extensions.md @@ -161,7 +161,21 @@ The decision features are extensions themselves: - **`with_tool_gate`** (a `before_tool`) and **`with_decision_model`** (a `before_model` note) are appended after the agent's own extensions, so the gate judges the final arguments. Installed with `with_tree_extension`, the gate runs first instead and rechecks rewritten calls (a second decision request). - **`with_input_guard`** (an `on_input`) is an ordinary extension at its installation position: it screens after every input filter. -So is the [`yoagent-rutis`](https://github.com/yologdev/yoagent/tree/main/integrations/yoagent-rutis) bridge (a separate crate, not yet on crates.io): `RutisBridge::extension()` is one extension over the handlers that [rutis](https://crates.io/crates/rutis) plugins register and unregister at runtime, snapshotted at each run's start. The host keeps the decisions plugins must not make: `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`. +So is the [`yoagent-rutis`](https://github.com/yologdev/yoagent/tree/main/integrations/yoagent-rutis) bridge (a separate crate, [on crates.io](https://crates.io/crates/yoagent-rutis)): `RutisBridge::extension()` is one extension over the handlers that [rutis](https://crates.io/crates/rutis) plugins register and unregister at runtime, snapshotted at each run's start. The host keeps the decisions plugins must not make: `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`. + +## One contract, other ecosystems + +`Extension` is the whole contract a plugin system needs: tools, policies, notes, verifiers and events go through the same hooks whichever ecosystem a plugin comes from. Through the rutis bridge, three foreign ecosystems already plug into yoagent with **no change to yoagent's core**: + +| Ecosystem | What its plugins are | How they reach yoagent | What crosses | +|---|---|---|---| +| [DSH](https://github.com/yologdev/yoagent/tree/main/integrations/yoagent-rutis#dsh-deepseek-harness-tool-plugins) (DeepSeek Harness) | Cordis plugins in Node | a small adapter over DSH's own tool registry (`plugins/dsh/`) | tools (images included), the system-prompt sections plugins add (as a turn note), cancellation | +| [rutis-agent](https://github.com/yologdev/yoagent/tree/main/integrations/yoagent-rutis#rutis-agent-tools) | Rust tools in a rutis registry | a Rust rutis plugin (`examples/rutis-agent-tools/`) | tools (images by convention), hot-added tools, cancellation | +| [pi](https://github.com/earendil-works/pi) | TypeScript extensions written against pi's `ExtensionAPI` | an adapter that loads them with pi's own loader (experimental, [yologdev/yoagent#265](https://github.com/yologdev/yoagent/pull/265)) | tools, tool policies, input checks, prompt additions, images | + +What the bridge gives every ecosystem alike: TypeScript and Python handlers next to Rust ones, one registration order, **image** tool results both ways (`content` blocks), **plugin logs** in the host's `tracing` output (`yoagent.log`, with the run they belong to), and failures that deny rather than allow. What stays outside the loop on purpose — commands, dialogs, session history, a UI — belongs to the app that hosts the agent. + +**The adapter contract.** An ecosystem rarely maps one-to-one, so every adapter follows the same rules ([details](https://github.com/yologdev/yoagent/tree/main/integrations/yoagent-rutis#writing-an-ecosystem-adapter)): map only what the host can honour; refuse to load when a plugin would decide or rewrite something the host cannot enforce; deny a call when a policy fails; withhold a result when a redaction fails; report — never silently drop — what is ignored. ## Order with the older hooks diff --git a/integrations/yoagent-rutis/Cargo.toml b/integrations/yoagent-rutis/Cargo.toml index 63e211f..059782f 100644 --- a/integrations/yoagent-rutis/Cargo.toml +++ b/integrations/yoagent-rutis/Cargo.toml @@ -65,6 +65,8 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync" # loads plugins itself: that is the host's job. rutis-loader = { version = "0.7", features = ["node", "python"] } tempfile = "3" +# Captures the bridge's `log` output in its own test binary (tests/plugin_log_test.rs). +tracing-subscriber = { version = "0.3", default-features = false, features = ["registry"] } [[test]] name = "languages_test" @@ -74,6 +76,10 @@ required-features = ["node", "python"] name = "dsh_test" required-features = ["node"] +[[test]] +name = "plugin_log_test" +required-features = ["node"] + [[example]] name = "language_plugins" required-features = ["node", "python"] diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 9e40160..b8e5046 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -230,6 +230,14 @@ def apply(ctx, config): return { content: [{ type: 'text', text: 'the chart' }, { type: 'image', data: png.toString('base64'), mimeType: 'image/png' }] } } ``` +- **Logs reach the host.** `yoagent.log(level, message, { run_id }?)` + (`error`, `warn`, `info`, `debug`; fire-and-forget; over 8192 characters + cut) writes to the host's `tracing` output under the target + `yoagent_rutis::plugin`, where a terminal or service host shows it — a + runtime process's own stderr may go nowhere. Pass the hook argument's + `run_id` to attribute the line to its run (the event's `run_id` field). + On an older host rutis's stand-in for the method throws: wrap the call + and fall back to `console.warn`. - **The bridge never loads plugins**: the host does, typically with [rutis-loader](https://crates.io/crates/rutis-loader) rows, and must share `yoagent` in the loader's catalog (`catalog.register_shared("yoagent")` or @@ -274,7 +282,7 @@ sits inside every run of every agent using the extension: The end-to-end tests cover local Node and Python runtimes; a remote node over `wss` is not tested here. -## Tools from other rutis ecosystems +## Tools from other agent ecosystems ### dsh (DeepSeek Harness) tool plugins @@ -338,6 +346,41 @@ cargo run --manifest-path examples/rutis-agent-tools/Cargo.toml [-- --live] > tag (`[patch.crates-io]`), leaving one `rutis` in the graph (`cargo tree -d` > shows none twice). Move the tag and the patch together. +## Writing an ecosystem adapter + +An adapter makes another plugin system's plugins (DSH's, rutis-agent's, +pi's) into handlers. Their APIs rarely map one-to-one — an ecosystem has +commands, dialogs, sessions, model routing — so every adapter follows the +same rules, learned from the three above. The point is that **a plugin's +safety policy never silently stops applying**: + +1. **Map only what the host can honour.** Tools, `before_tool` / + `after_tool` policies, input checks, turn notes, verifiers and events + have a yoagent hook; offer those. +2. **Refuse to load what would decide or rewrite unenforced.** A plugin + handler for something the host never does — rewriting the conversation, + rewriting provider requests — would be a policy that never runs. Fail + the load (the host may accept it explicitly, by name), and when such a + registration appears after load, stop: deny every call, reject input. +3. **A failing policy denies; a failing redaction withholds.** A thrown + error, a timeout or a malformed answer in a `before_tool`-like hook is a + denial; in an `after_tool`-like hook the result is withheld, never + passed through raw. (The bridge already treats a handler's failures + this way; an adapter must not catch them into an allow.) +4. **Name clashes refuse.** A plugin tool named like one of the host's + tools would lose to it and leave its policy judging the wrong tool: + refuse unless the host says it left its own out. +5. **Report what is ignored — through the host.** Commands, renderers, + notifications and other app-level parts warn (or refuse with a strict + option) through `yoagent.log`, never silently. +6. **Keep plugin state local, don't fail work.** A plugin that records its + own state (a session entry, a result card) gets an adapter-local store + rather than an error, so a tool does not fail after its work is done; + what would inject input or start a turn still throws. +7. **No UI means "no".** With no one to ask, a dialog answers as a + headless run does — a confirmation is declined — so a policy that would + ask denies. + ## Semantics ### Runs and plugin lifecycles diff --git a/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts b/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts index 294078e..0d65b27 100644 --- a/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts +++ b/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts @@ -143,7 +143,13 @@ export default definePlugin({ return { type: 'image', data: Buffer.from(stored.data).toString('base64'), mimeType: stored.ref.mediaType } } catch (error) { if (signal.aborted) throw error - console.warn(`[dsh] image ${ref.attachmentId} could not be read: ${error}`) + const message = `[dsh] image ${ref.attachmentId} could not be read: ${error}` + // On a host without `log`, rutis's stand-in throws: fall back either way. + try { + yoagent.log?.('warn', message)?.catch(() => console.warn(message)) + } catch { + console.warn(message) + } return { type: 'text', text: label } } } diff --git a/integrations/yoagent-rutis/plugins/yoagent.d.ts b/integrations/yoagent-rutis/plugins/yoagent.d.ts index 466ef9f..a8882d7 100644 --- a/integrations/yoagent-rutis/plugins/yoagent.d.ts +++ b/integrations/yoagent-rutis/plugins/yoagent.d.ts @@ -39,6 +39,17 @@ export interface Yoagent { * `on_input` rejects, its `after_tool` withholds the result. */ register(name: string, handler: Handler, options?: Options): () => void + /** + * Write a diagnostic to the host's logs (its `tracing` output, target + * `yoagent_rutis::plugin`) rather than this process's stderr, which a + * terminal or service host may not show. Levels `error`, `warn`, `info`, + * `debug`; messages over 8192 characters are cut. Fire-and-forget. Pass + * `{ run_id }` (from any hook argument) to attribute the line to its run: + * it becomes the event's `run_id` field. On a host older than + * yoagent-rutis 0.2 rutis's stand-in for it throws: wrap the call + * (`try { yoagent.log(l, m).catch(f) } catch { f() }`). + */ + log?(level: 'error' | 'warn' | 'info' | 'debug', message: string, context?: { run_id?: string }): Promise } export interface Options { diff --git a/integrations/yoagent-rutis/src/languages.rs b/integrations/yoagent-rutis/src/languages.rs index c714bf4..db566e3 100644 --- a/integrations/yoagent-rutis/src/languages.rs +++ b/integrations/yoagent-rutis/src/languages.rs @@ -142,6 +142,7 @@ impl HostDispatch for Service { fn invoke(&self, method: &str, args: Value) -> Reply { match method { "register" => self.register(args), + "log" => log(args), other => Err(invalid(format!( "the yoagent service has no method `{other}`" ))), @@ -151,11 +152,61 @@ impl HostDispatch for Service { fn methods(&self) -> Option { // Synchronous: `register` returns the disposer the plugin passes to // `ctx.effect`. It reads the handler's members back while the plugin - // waits, on the same call chain. - Some(json!({ "register": "sync" })) + // waits, on the same call chain. `log` is fire-and-forget. + Some(json!({ "register": "sync", "log": "async" })) } } +/// Longest message `log` writes, in characters; the rest is cut (a plugin cannot flood the host's logs). +const MAX_LOG_CHARS: usize = 8 * 1024; + +/// `log(level, message, context?)`: a plugin's diagnostic in the host's +/// `tracing` output (target `yoagent_rutis::plugin`), not on the plugin +/// process's stderr. Levels `error`, `warn`, `info`, `debug`; any other is +/// `warn`. `context.run_id` (the `run_id` every hook argument carries) is +/// recorded as the event's `run_id` field, so a consumer — a GASP recorder, +/// a log viewer — can attribute the line to its run. +fn log(args: Value) -> Reply { + let mut args = args.list()?.into_iter(); + let mut text = |what: &str| -> Result { + match args.next() { + Some(value) => session::decode::(value.json()?), + None => Err(invalid(format!( + "log(level, message, context?): missing the {what}" + ))), + } + }; + let level = text("level")?; + let message = text("message")?; + let context: LogContext = match args.next() { + None | Some(Value::Undefined) => LogContext::default(), + Some(value) => match value.json()? { + Json::Null => LogContext::default(), + json => session::decode(json)?, + }, + }; + let message: String = match message.char_indices().nth(MAX_LOG_CHARS) { + Some((cut, _)) => format!("{}… (cut)", &message[..cut]), + None => message, + }; + let run_id = context.run_id.as_deref().unwrap_or(""); + match level.as_str() { + "error" => tracing::error!(target: "yoagent_rutis::plugin", run_id, "{message}"), + "info" => tracing::info!(target: "yoagent_rutis::plugin", run_id, "{message}"), + "debug" => tracing::debug!(target: "yoagent_rutis::plugin", run_id, "{message}"), + _ => tracing::warn!(target: "yoagent_rutis::plugin", run_id, "{message}"), + } + Ok(Value::Undefined) +} + +/// `log`'s third argument. +#[derive(Default, serde::Deserialize)] +#[serde(default, deny_unknown_fields)] +struct LogContext { + /// The run the line belongs to (empty when none). + run_id: Option, +} + /// `register`'s third argument. #[derive(Default, serde::Deserialize)] #[serde(default, deny_unknown_fields)] diff --git a/integrations/yoagent-rutis/tests/plugin_log_test.rs b/integrations/yoagent-rutis/tests/plugin_log_test.rs new file mode 100644 index 0000000..d91a8e2 --- /dev/null +++ b/integrations/yoagent-rutis/tests/plugin_log_test.rs @@ -0,0 +1,145 @@ +//! A TypeScript plugin's diagnostics reach the host's `tracing` output +//! through the bridge's `log` method (target `yoagent_rutis::plugin`), not +//! only the plugin process's stderr — with the run they belong to, when the +//! plugin says, as a `run_id` field. +//! +//! Its own test binary with a single test: it installs a global subscriber +//! (the bridge logs from Tokio's worker threads). Needs Node 24+ and `npm ci` +//! in `plugins/`; without them it prints `SKIPPED:` and passes, or fails +//! with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use rutis::Ctx; +use rutis_bridge::runtime::LocalRuntime; +use rutis_loader::{ + Chain, Layer, LoaderOptions, LoaderPlugin, Patch, RuntimeResolver, RuntimeRowsPlugin, + ServiceCatalog, +}; +use serde_json::json; +use tracing_subscriber::layer::SubscriberExt; +use yoagent_rutis::RutisBridge; + +/// Every event as `target level run_id=<..> message`. +#[derive(Clone, Default)] +struct CapturedLogs(Arc>>); + +impl tracing_subscriber::Layer for CapturedLogs { + fn on_event(&self, event: &tracing::Event<'_>, _: tracing_subscriber::layer::Context<'_, S>) { + #[derive(Default)] + struct Fields { + message: String, + run_id: String, + } + impl tracing::field::Visit for Fields { + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + if field.name() == "run_id" { + self.run_id = value.to_string(); + } + } + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.message = format!("{value:?}"); + } + } + } + let mut fields = Fields::default(); + event.record(&mut fields); + let meta = event.metadata(); + self.0.lock().unwrap().push(format!( + "{} {} run_id={} {}", + meta.target(), + meta.level(), + fields.run_id, + fields.message + )); + } +} + +fn plugins_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins") +} + +/// Logs twice when it loads: a warning for a run, an error without one. +const JS_LOGGER: &str = r#" +import { definePlugin } from 'RUTIS' +export default definePlugin({ + inject: ['yoagent'], + apply(ctx) { + const yoagent = ctx.use('yoagent') + yoagent.log('warn', 'hello from js', { run_id: 'run-7' }) + yoagent.log('error', 'no run here') + }, +}) +"#; + +#[tokio::test(flavor = "multi_thread")] +async fn a_plugin_log_reaches_the_hosts_tracing_with_its_run() { + let runtime = plugins_dir().join("node_modules/@arcships/rutis-runtime"); + if !runtime.join("package.json").exists() { + if std::env::var_os("YOAGENT_RUTIS_REQUIRE_RUNTIMES").is_some_and(|v| v == "1") { + panic!("the Node runtime is required but unavailable: run `npm ci` in plugins/"); + } + eprintln!("SKIPPED: run `npm ci` in plugins/"); + return; + } + let logs = CapturedLogs::default(); + tracing::subscriber::set_global_default(tracing_subscriber::registry().with(logs.clone())) + .unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let rutis = plugins_dir() + .join("node_modules/@arcships/rutis/src/index.mjs") + .canonicalize() + .map(|p| format!("file://{}", p.display())) + .unwrap(); + let logger = dir.path().join("logger.mjs"); + std::fs::write(&logger, JS_LOGGER.replace("RUTIS", &rutis)).unwrap(); + + let root = Ctx::root().unwrap(); + let _bridge = RutisBridge::install(&root).unwrap(); + let mut catalog = ServiceCatalog::new(); + catalog.register_shared("yoagent"); + let node = LocalRuntime::node(&runtime, plugins_dir().join("package.json")); + let resolver = Arc::new(RuntimeResolver::node(node.handle()).with_catalog(&catalog)); + root.plugin(node); + let plugin = LoaderPlugin::new( + Chain::new().with_shared(resolver.clone()), + LoaderOptions { + catalog, + ..LoaderOptions::default() + }, + ); + let loader = plugin.handle(); + root.plugin(plugin).await.unwrap(); + root.plugin(RuntimeRowsPlugin::new(resolver)); + let patches: Vec = serde_json::from_value(json!([{ "insert": [{ + "id": "logger", + "name": logger.to_string_lossy(), + }] }])) + .unwrap(); + loader + .reconcile(vec![Layer::new("rows", patches)], None) + .await + .unwrap(); + + let want = [ + "yoagent_rutis::plugin WARN run_id=run-7 hello from js", + "yoagent_rutis::plugin ERROR run_id= no run here", + ]; + tokio::time::timeout(Duration::from_secs(60), async { + loop { + let seen = logs.0.lock().unwrap().clone(); + if want.iter().all(|w| seen.iter().any(|l| l == w)) { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("plugin logs missing: {:?}", logs.0.lock().unwrap())); + root.shutdown().await.unwrap(); +} From 50b0d406b0b6c46e7c8a49a3bd7c96085047f620 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 23:14:18 +0200 Subject: [PATCH 2/2] fix(rutis): log never rejects; docs say what is in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #268: - log never returns an error: a rejected async call a plugin does not catch ends its Node runtime (no unhandledRejection handler). Any message (non-strings as JSON), any context (other fields ignored, e.g. a whole hook argument). Test: a malformed uncaught call, then 'still alive'. - Python: log is a coroutine — documented as await yoagent.log(...) - docs: two ecosystems plug in today, pi is in review (#265); the adapter rules are a contract ('should'), with which rules the DSH adapter meets Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 4 +- CLAUDE.md | 2 +- docs/concepts/extensions.md | 4 +- integrations/yoagent-rutis/README.md | 28 ++++++---- .../yoagent-rutis/plugins/yoagent.d.ts | 13 +++-- integrations/yoagent-rutis/src/languages.rs | 51 +++++++++---------- .../yoagent-rutis/tests/plugin_log_test.rs | 5 ++ 7 files changed, 59 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3be9c2..7a244ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ adheres to [Semantic Versioning](https://semver.org/). ### yoagent-rutis - **Images in tool results, across ecosystems.** TypeScript and Python handlers' `call_tool` results and `after_tool` edits take `content` blocks in yoagent's JSON shape (`{"type": "image", "data": , "mimeType"}` next to text blocks) instead of `text`, so pictures cross the bridge both ways (`after_tool` already saw `output.content`). The dsh adapter turns dsh image blocks (references into dsh's attachment store) into yoagent images through the `attachments` service when one is loaded; the rutis-agent example reads a runner's `{"content": [...]}` value as blocks (rutis-agent results are otherwise text). Rust handlers already returned full yoagent tool results. Tests: `content_blocks` unit test, TypeScript/Python round trip in `languages_test`, dsh with and without a store in `dsh_test`. -- **Plugin logs reach the host.** The `yoagent` service gains `log(level, message, { run_id }?)`: a TypeScript/Python plugin's diagnostics go to the host's `tracing` output (target `yoagent_rutis::plugin`, with the run as a `run_id` field) instead of the runtime process's stderr. The dsh adapter uses it. Test: `tests/plugin_log_test.rs`. -- **Docs: one contract, other ecosystems.** The extensions guide shows DSH, rutis-agent and pi plugging in through `Extension` and the bridge with no core change; the bridge README states the adapter contract (map what the host honours, refuse what would decide unenforced, deny on failing policies, withhold on failing redactions, report what is ignored). CI now also runs the rutis-agent example. +- **Plugin logs reach the host.** The `yoagent` service gains `log(level, message, { run_id }?)`: a TypeScript/Python plugin's diagnostics go to the host's `tracing` output (target `yoagent_rutis::plugin`, with the run as a `run_id` field) instead of the runtime process's stderr. It never rejects (a rejected, uncaught call would end the plugin's runtime): any message, extra context fields ignored. In Python it is a coroutine to `await`. The dsh adapter uses it. Test: `tests/plugin_log_test.rs`. +- **Docs: one contract, other ecosystems.** The extensions guide shows DSH and rutis-agent plugging in through `Extension` and the bridge with no core change (pi in review, #265); the bridge README states the adapter contract (map what the host honours, refuse what would decide unenforced, deny on failing policies, withhold on failing redactions, report what is ignored). CI now also runs the rutis-agent example. - Test fix: `dsh_test` no longer reads the abort file mid-write (empty) as the result. ## 0.25.0 (2026-10-08) diff --git a/CLAUDE.md b/CLAUDE.md index e022afb..812048c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### rutis bridge (`integrations/yoagent-rutis/`, separate crate) -Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.6"` (0.x caret = 0.6.x; any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Python plugins** (`languages.rs`, features `node`/`python`/`websocket` → optional `rutis-bridge = "0.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (`LogContext`, deny unknown fields; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (websocket frame limit 16 MiB), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7 allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.7.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY` / `~/.dskey`; self-checks). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.7.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.7.0), `requirements.txt` (`rutis==0.7.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 24 / `plugins/node_modules` / Python with rutis 0.7 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.86, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 24, Python 3.12: `npm ci` in `plugins/` and `plugins/dsh/`, clippy per feature, docs, tests with the runtimes required, the `language_plugins` example; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. +Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.6"` (0.x caret = 0.6.x; any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Python plugins** (`languages.rs`, features `node`/`python`/`websocket` → optional `rutis-bridge = "0.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (websocket frame limit 16 MiB), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7 allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.7.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY` / `~/.dskey`; self-checks). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.7.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.7.0), `requirements.txt` (`rutis==0.7.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 24 / `plugins/node_modules` / Python with rutis 0.7 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.86, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 24, Python 3.12: `npm ci` in `plugins/` and `plugins/dsh/`, clippy per feature, docs, tests with the runtimes required, the `language_plugins` example; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/docs/concepts/extensions.md b/docs/concepts/extensions.md index 5f5e24c..1be9329 100644 --- a/docs/concepts/extensions.md +++ b/docs/concepts/extensions.md @@ -165,7 +165,7 @@ So is the [`yoagent-rutis`](https://github.com/yologdev/yoagent/tree/main/integr ## One contract, other ecosystems -`Extension` is the whole contract a plugin system needs: tools, policies, notes, verifiers and events go through the same hooks whichever ecosystem a plugin comes from. Through the rutis bridge, three foreign ecosystems already plug into yoagent with **no change to yoagent's core**: +`Extension` is the whole contract a plugin system needs: tools, policies, notes, verifiers and events go through the same hooks whichever ecosystem a plugin comes from. Through the rutis bridge, two foreign ecosystems plug into yoagent today and a third is in review — all with **no change to yoagent's core**: | Ecosystem | What its plugins are | How they reach yoagent | What crosses | |---|---|---|---| @@ -175,7 +175,7 @@ So is the [`yoagent-rutis`](https://github.com/yologdev/yoagent/tree/main/integr What the bridge gives every ecosystem alike: TypeScript and Python handlers next to Rust ones, one registration order, **image** tool results both ways (`content` blocks), **plugin logs** in the host's `tracing` output (`yoagent.log`, with the run they belong to), and failures that deny rather than allow. What stays outside the loop on purpose — commands, dialogs, session history, a UI — belongs to the app that hosts the agent. -**The adapter contract.** An ecosystem rarely maps one-to-one, so every adapter follows the same rules ([details](https://github.com/yologdev/yoagent/tree/main/integrations/yoagent-rutis#writing-an-ecosystem-adapter)): map only what the host can honour; refuse to load when a plugin would decide or rewrite something the host cannot enforce; deny a call when a policy fails; withhold a result when a redaction fails; report — never silently drop — what is ignored. +**The adapter contract.** An ecosystem rarely maps one-to-one, so an adapter should follow the same rules ([details](https://github.com/yologdev/yoagent/tree/main/integrations/yoagent-rutis#writing-an-ecosystem-adapter)): map only what the host can honour; refuse to load when a plugin would decide or rewrite something the host cannot enforce; deny a call when a policy fails; withhold a result when a redaction fails; report — never silently drop — what is ignored. ## Order with the older hooks diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index b8e5046..9aa02fa 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -231,13 +231,16 @@ def apply(ctx, config): } ``` - **Logs reach the host.** `yoagent.log(level, message, { run_id }?)` - (`error`, `warn`, `info`, `debug`; fire-and-forget; over 8192 characters - cut) writes to the host's `tracing` output under the target - `yoagent_rutis::plugin`, where a terminal or service host shows it — a - runtime process's own stderr may go nowhere. Pass the hook argument's - `run_id` to attribute the line to its run (the event's `run_id` field). - On an older host rutis's stand-in for the method throws: wrap the call - and fall back to `console.warn`. + (`error`, `warn`, `info`, `debug`; over 8192 characters cut) writes to + the host's `tracing` output under the target `yoagent_rutis::plugin`, + where a terminal or service host shows it — a runtime process's own + stderr may go nowhere. Pass `{ run_id }`, or the hook argument itself, to + attribute the line to its run (the event's `run_id` field). It never + rejects (any message, extra context fields ignored). Fire-and-forget in + JavaScript; in Python `await yoagent.log(...)` — an un-awaited coroutine + is never sent. On a host without it (yoagent-rutis 0.1.0) rutis's + stand-in throws (Python: `AttributeError`): wrap the call and fall back + to `console.warn` / `print`. - **The bridge never loads plugins**: the host does, typically with [rutis-loader](https://crates.io/crates/rutis-loader) rows, and must share `yoagent` in the loader's catalog (`catalog.register_shared("yoagent")` or @@ -350,9 +353,14 @@ cargo run --manifest-path examples/rutis-agent-tools/Cargo.toml [-- --live] An adapter makes another plugin system's plugins (DSH's, rutis-agent's, pi's) into handlers. Their APIs rarely map one-to-one — an ecosystem has -commands, dialogs, sessions, model routing — so every adapter follows the -same rules, learned from the three above. The point is that **a plugin's -safety policy never silently stops applying**: +commands, dialogs, sessions, model routing — so this is the contract an +adapter should follow, learned from the DSH and rutis-agent adapters above +and the pi adapter in review ([#265](https://github.com/yologdev/yoagent/pull/265)). +The point is that **a plugin's safety policy never silently stops +applying**. Not every rule arises for every adapter: the DSH adapter maps +only tools and prompt sections, so it has nothing to refuse (2) or record +(6), and today relies on yoagent's own handling of a name clash (the host's +tool wins, with a warning) rather than refusing (4). 1. **Map only what the host can honour.** Tools, `before_tool` / `after_tool` policies, input checks, turn notes, verifiers and events diff --git a/integrations/yoagent-rutis/plugins/yoagent.d.ts b/integrations/yoagent-rutis/plugins/yoagent.d.ts index a8882d7..a85aa8a 100644 --- a/integrations/yoagent-rutis/plugins/yoagent.d.ts +++ b/integrations/yoagent-rutis/plugins/yoagent.d.ts @@ -43,11 +43,14 @@ export interface Yoagent { * Write a diagnostic to the host's logs (its `tracing` output, target * `yoagent_rutis::plugin`) rather than this process's stderr, which a * terminal or service host may not show. Levels `error`, `warn`, `info`, - * `debug`; messages over 8192 characters are cut. Fire-and-forget. Pass - * `{ run_id }` (from any hook argument) to attribute the line to its run: - * it becomes the event's `run_id` field. On a host older than - * yoagent-rutis 0.2 rutis's stand-in for it throws: wrap the call - * (`try { yoagent.log(l, m).catch(f) } catch { f() }`). + * `debug`; messages over 8192 characters are cut. It never rejects: any + * message (a non-string is written as JSON) and any context are taken, + * other context fields ignored. Pass `{ run_id }` — or a hook argument + * itself, which carries it — to attribute the line to its run: it becomes + * the event's `run_id` field. Fire-and-forget in JavaScript; in Python it + * is a coroutine: `await yoagent.log(...)`, or it is never sent. On a host + * without it (yoagent-rutis 0.1.0) rutis's stand-in throws (Python: an + * `AttributeError` on access): wrap the call and fall back. */ log?(level: 'error' | 'warn' | 'info' | 'debug', message: string, context?: { run_id?: string }): Promise } diff --git a/integrations/yoagent-rutis/src/languages.rs b/integrations/yoagent-rutis/src/languages.rs index db566e3..42a9c04 100644 --- a/integrations/yoagent-rutis/src/languages.rs +++ b/integrations/yoagent-rutis/src/languages.rs @@ -162,34 +162,37 @@ const MAX_LOG_CHARS: usize = 8 * 1024; /// `log(level, message, context?)`: a plugin's diagnostic in the host's /// `tracing` output (target `yoagent_rutis::plugin`), not on the plugin -/// process's stderr. Levels `error`, `warn`, `info`, `debug`; any other is -/// `warn`. `context.run_id` (the `run_id` every hook argument carries) is +/// process's stderr. Levels `error`, `warn`, `info`, `debug`; anything else +/// is `warn`. `context.run_id` (the `run_id` every hook argument carries) is /// recorded as the event's `run_id` field, so a consumer — a GASP recorder, /// a log viewer — can attribute the line to its run. +/// +/// **Never fails.** A plugin calls it fire-and-forget, and a rejected call +/// it does not catch would end its runtime process. So any value is taken: +/// a non-string message is written as JSON, other context fields (a whole +/// hook argument, say) are ignored, a missing argument is empty. fn log(args: Value) -> Reply { - let mut args = args.list()?.into_iter(); - let mut text = |what: &str| -> Result { - match args.next() { - Some(value) => session::decode::(value.json()?), - None => Err(invalid(format!( - "log(level, message, context?): missing the {what}" - ))), - } - }; - let level = text("level")?; - let message = text("message")?; - let context: LogContext = match args.next() { - None | Some(Value::Undefined) => LogContext::default(), - Some(value) => match value.json()? { - Json::Null => LogContext::default(), - json => session::decode(json)?, - }, + let args: Vec = args + .list() + .map(|list| list.into_iter().filter_map(|v| v.json().ok()).collect()) + .unwrap_or_default(); + let text = |value: Option<&Json>| match value { + Some(Json::String(text)) => text.clone(), + Some(Json::Null) | None => String::new(), + Some(other) => other.to_string(), }; + let level = text(args.first()); + let message = text(args.get(1)); + let run_id = args + .get(2) + .and_then(|context| context.get("run_id")) + .map(|id| text(Some(id))) + .unwrap_or_default(); let message: String = match message.char_indices().nth(MAX_LOG_CHARS) { Some((cut, _)) => format!("{}… (cut)", &message[..cut]), None => message, }; - let run_id = context.run_id.as_deref().unwrap_or(""); + let run_id = run_id.as_str(); match level.as_str() { "error" => tracing::error!(target: "yoagent_rutis::plugin", run_id, "{message}"), "info" => tracing::info!(target: "yoagent_rutis::plugin", run_id, "{message}"), @@ -199,14 +202,6 @@ fn log(args: Value) -> Reply { Ok(Value::Undefined) } -/// `log`'s third argument. -#[derive(Default, serde::Deserialize)] -#[serde(default, deny_unknown_fields)] -struct LogContext { - /// The run the line belongs to (empty when none). - run_id: Option, -} - /// `register`'s third argument. #[derive(Default, serde::Deserialize)] #[serde(default, deny_unknown_fields)] diff --git a/integrations/yoagent-rutis/tests/plugin_log_test.rs b/integrations/yoagent-rutis/tests/plugin_log_test.rs index d91a8e2..028ecfb 100644 --- a/integrations/yoagent-rutis/tests/plugin_log_test.rs +++ b/integrations/yoagent-rutis/tests/plugin_log_test.rs @@ -72,6 +72,9 @@ export default definePlugin({ const yoagent = ctx.use('yoagent') yoagent.log('warn', 'hello from js', { run_id: 'run-7' }) yoagent.log('error', 'no run here') + // Malformed, and not caught: never rejects, so the runtime lives on. + yoagent.log('warn', { not: 'a string' }, { run_id: 'run-8', tool: 'bash', args: {} }) + setTimeout(() => yoagent.log('info', 'still alive'), 500) }, }) "#; @@ -129,6 +132,8 @@ async fn a_plugin_log_reaches_the_hosts_tracing_with_its_run() { let want = [ "yoagent_rutis::plugin WARN run_id=run-7 hello from js", "yoagent_rutis::plugin ERROR run_id= no run here", + r#"yoagent_rutis::plugin WARN run_id=run-8 {"not":"a string"}"#, + "yoagent_rutis::plugin INFO run_id= still alive", ]; tokio::time::timeout(Duration::from_secs(60), async { loop {