Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": <base64>, "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. 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)
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion docs/concepts/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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 |
|---|---|---|---|
| [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 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

Expand Down
6 changes: 6 additions & 0 deletions integrations/yoagent-rutis/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"]
Expand Down
53 changes: 52 additions & 1 deletion integrations/yoagent-rutis/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,17 @@ 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`; 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
Expand Down Expand Up @@ -274,7 +285,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

Expand Down Expand Up @@ -338,6 +349,46 @@ 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 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
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
Expand Down
8 changes: 7 additions & 1 deletion integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,13 @@ export default definePlugin<Config>({
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 }
}
}
Expand Down
14 changes: 14 additions & 0 deletions integrations/yoagent-rutis/plugins/yoagent.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ 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. 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<void>
}

export interface Options {
Expand Down
50 changes: 48 additions & 2 deletions integrations/yoagent-rutis/src/languages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`"
))),
Expand All @@ -151,11 +152,56 @@ impl HostDispatch for Service {
fn methods(&self) -> Option<Json> {
// 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`; 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 args: Vec<Json> = 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 = 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}"),
"debug" => tracing::debug!(target: "yoagent_rutis::plugin", run_id, "{message}"),
_ => tracing::warn!(target: "yoagent_rutis::plugin", run_id, "{message}"),
}
Ok(Value::Undefined)
}

/// `register`'s third argument.
#[derive(Default, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
Expand Down
Loading
Loading