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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,10 @@ jobs:
python-version: "3.12"
- name: Install the rutis npm packages
run: npm ci --prefix "$PLUGINS"
# The dsh adapter's end-to-end test (tests/dsh_test.rs): dsh-tools,
# dsh-system-prompt and the adapter's other pins, in their own install.
- name: Install the dsh npm packages
run: npm ci --prefix "$PLUGINS/dsh"
- name: Install the rutis Python package
run: |
python -m venv "$PLUGINS/.venv"
Expand Down
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@ adheres to [Semantic Versioning](https://semver.org/).
- **`LlmCompaction::reprice(&self)`** and a defaulted `CompactionStrategy::reprice`; `Agent::reprice` now also re-prices the compaction summarizer, so an agent built before the pricing opt-in can be priced afterwards.
- **A one-time `warn!` per model when a first-party price lookup misses** while only `YOAGENT_PRICES` / `install_override` / `install_fetched` is in effect (no bundled snapshot). `LivePrices` is `#[must_use]`.
- **`SessionStats::compaction`** (`CompactionSpend { usage, cost_usd, requests }`) and **`Agent::compaction_spend()`**: what `LlmCompaction`'s summarization requests cost, priced at the summarizer's own `cost`. See the fix below.
- **`yoagent-rutis` (unpublished): TypeScript and Python hooks get a cancel handle.** Every hook but `on_event` finds it in its first argument, as the field `signal`: a real `AbortSignal` in JavaScript, rutis's `Signal` in Python (`.cancelled`, `await signal.wait()`). It is aborted when the bridge gives the call up — the run cancelled (`Agent::abort()`), the hook's timeout passed, the plugin unloaded mid-call — and never once the call completed. A `call_tool` that passes `call.signal` on (to `fetch`, a child process, dsh's `execute`) now stops when its run is cancelled; before, a JavaScript hook always ran to completion. A field rather than an extra positional argument, so fixed-signature Python methods keep working. It is not plain data: Python's `json.dumps(call)` now refuses the argument (drop `signal` first). Tested end to end in both languages (cancel and timeout abort it; a completed call's handle never is). No yoagent core change.
- **`yoagent-rutis` (unpublished): dsh tools as yoagent tools.** `plugins/dsh/dsh-tools-adapter.ts` offers every tool of a DeepSeek Harness tool registry (`@deepseek-ai/dsh-tools`' `tools` service) to yoagent agents: `call_tool` runs dsh's `execute` with the bridge's cancel handle as dsh's `signal`, an `isError` result is an error tool result, and `before_model` adds the system-prompt sections dsh plugins wrote (the harness's own left out, capped at 2000 characters) as a turn note. dsh plugins run unchanged in rutis's Node runtime; `plugins/dsh/package.json` pins dsh 0.2.0-rc.2, `@deepseek-ai/cordis` 4.0.4 and `dsh-free-search` 0.8.1, in its own install (the language tests' stays small). `examples/dsh_tools.rs` (feature `node`) runs the unchanged `dsh-free-search` plugin's `platform_search` for a scripted model (it searches the web: needs network) or DeepSeek (`--live`) and checks its outcome. `tests/dsh_test.rs` covers the adapter with a no-network fixture dsh plugin (`plugins/dsh/fixture-tools.ts`): tools offered, text, `isError`, the prompt note, and a cancelled run aborting a slow dsh tool through its signal; CI's `rutis-bridge-languages` job installs `plugins/dsh` too.
- **`yoagent-rutis` (unpublished): rutis-agent's tools as yoagent tools**, an example workspace (`examples/rutis-agent-tools/`, its own `[workspace]`, not built with the crate): a rutis plugin maps every `ToolDef` of rutis-agent's `ToolRegistry` to a yoagent tool per run (failures are `ToolError`s, yoagent's cancel token is the one rutis-agent's `execute` watches), shown with rutis-agent's real `replace_text`, a tool registered into the registry while the host runs (offered on the next run), and a cancelled call; scripted, or `--live` with DeepSeek, self-checking. **rutis-agent comes from git** (the rutis repository at tag v0.7.0, the one on rutis 0.6; crates.io's 0.2.0 is on rutis 0.2) **with a `[patch.crates-io]` for `rutis`**, so the graph holds one `rutis` crate.

### Changed

- **The Azure OpenAI and OpenAI Responses providers share their request code** (#228). Azure's request builder and read loop were a copy of the Responses provider's; both now use one (`provider/responses_request.rs`), and Azure keeps only its endpoint, `api-key` header and legacy deployment override. The bodies they sent were already identical; the new fields above reach both.
- **`yoagent-rutis` (unpublished) moves to rutis 0.6** (`rutis = "0.6"`, built against 0.6.1; 0.6 made rutis's public types `#[non_exhaustive]`, 0.6.1 added optional APIs).
- **`yoagent-rutis` (unpublished) is rebuilt on `Extension`** (#250). `RutisBridge::install(&root)` provides a `yoagent` `Registry` service; plugins register a `Handler` (a name plus any of `tools`, `before_tool`, `after_tool`, `before_model`, `on_input`, `on_stop`, `finish`, `on_event`, with plain-data arguments: `ToolCall`, `Turn`, `Input`, `Stop`, `RunInfo`), tied to the plugin's lifecycle; `bridge.extension()` is one `impl Extension` (`RutisExtension`) whose `start_run` snapshots the handlers. The host sets `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()` and per-hook timeouts (60 s policy, 30 s input, 5 s turn). Installed with `with_tree_extension`, plugin policy now reaches sub-agents. New for plugins: `after_tool` (redaction), `on_stop` (verifiers), `finish`. Removed: the four adapters (`PluginToolSource`, `RutisToolMiddleware`, `RutisTurnHook`, `RutisInputFilter`), `attach` / `attach_sub_agent` / `AgentRutisExt`, `ToolRegistry`, `ToolCallEvent` / `ToolVerdict` / `ToolPolicy` / `TurnEvent` / `InputEvent`, and the `event_sender` tee (every run's events are now published on the bus from `on_event`, as `AgentEventEmitted` with `run_id()`, `label()` and `depth()`). The bus chains and their workarounds (frozen arguments, recorded denials, `mark_judged`, liveness checks around each dispatch) are gone with them. A handler whose plugin unloads mid-run is unavailable for that run: its tools fail, its `before_tool` denies, its `on_input` rejects, its `after_tool` withholds (without failing a `required()` run — an unload is not a failure). A failing `on_event` handler is switched off alone: bus publishing and the other handlers go on, and a `required()` run fails at its next decision point (a failure in the run's last events is logged).
- **`yoagent-rutis` (unpublished) runs TypeScript and Python plugins** through rutis-bridge 0.7 (#250; features `node`, `python`, `websocket`, passed through; the default build stays Rust-only). `RutisBridge::install` then also provides the registry as the host service `yoagent`: a plugin calls `register(name, handler, options?)` with an object (or Python dict) of async functions passed by reference, and passes the returned unregister function to `ctx.effect`; a runtime that exits or crashes has its handlers removed. Same hooks and plain-JSON arguments as Rust handlers; `on_event` is opt-in and filtered by event type (`options.events`). The handler shape ships as `plugins/yoagent.d.ts` (no SDK package yet), with one TypeScript and one Python example plugin, a host example (`examples/language_plugins.rs`), end-to-end tests against real Node 24 / Python 3.12 runtimes, and a CI job (`rutis-bridge-languages`). The bridge still builds on 1.86. An abandoned JavaScript hook is not stopped (no `AbortSignal` is passed), so a `call_tool` can still act after its run was cancelled; Python coroutines are cancelled.
- **`yoagent-rutis` (unpublished) runs TypeScript and Python plugins** through rutis-bridge 0.7 (#250; features `node`, `python`, `websocket`, passed through; the default build stays Rust-only). `RutisBridge::install` then also provides the registry as the host service `yoagent`: a plugin calls `register(name, handler, options?)` with an object (or Python dict) of async functions passed by reference, and passes the returned unregister function to `ctx.effect`; a runtime that exits or crashes has its handlers removed. Same hooks and plain-JSON arguments as Rust handlers; `on_event` is opt-in and filtered by event type (`options.events`). The handler shape ships as `plugins/yoagent.d.ts` (no SDK package yet), with one TypeScript and one Python example plugin, a host example (`examples/language_plugins.rs`), end-to-end tests against real Node 24 / Python 3.12 runtimes, and a CI job (`rutis-bridge-languages`). The bridge still builds on 1.86. An abandoned call is cancelled through the hook's `signal` (see Added); Python coroutines are cancelled too.
- **The decision features run as extensions** (#241 dogfooding). `with_tool_gate`, `with_input_guard` and `with_decision_model` install a `ToolGate` (`before_tool`), an `InputGuard` (`on_input`) and the advisor (a `before_model` note); the gate and guard keep their old trait impls, now deprecated (below). Three ordering effects:
- the gate runs after every middleware **and extension**, so it also judges arguments an extension rewrote;
- the guard screens after all input filters, not in their list;
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

18 changes: 16 additions & 2 deletions integrations/yoagent-rutis/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@ readme = "README.md"
keywords = ["agent", "llm", "plugin", "rutis", "yoagent"]
categories = ["asynchronous", "development-tools"]
authors = ["Yuanhao <yuanhao@yolog.dev>"]
# Installed locally for the end-to-end tests; never packaged.
exclude = ["plugins/node_modules", "plugins/.venv"]
# Installed locally for the end-to-end tests, and the rutis-agent example
# (its own workspace): never packaged.
exclude = [
"plugins/node_modules",
"plugins/.venv",
"plugins/dsh/node_modules",
"examples/rutis-agent-tools",
]
# yoagent's MSRV; rutis 0.6.1 declares 1.85.
rust-version = "1.86"
# Not published yet. It needs yoagent's `Extension`, which ships in 0.25.
Expand Down Expand Up @@ -64,6 +70,14 @@ tempfile = "3"
name = "languages_test"
required-features = ["node", "python"]

[[test]]
name = "dsh_test"
required-features = ["node"]

[[example]]
name = "language_plugins"
required-features = ["node", "python"]

[[example]]
name = "dsh_tools"
required-features = ["node"]
90 changes: 79 additions & 11 deletions integrations/yoagent-rutis/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,17 +187,28 @@ def apply(ctx, config):
`before_tool` denies, its `on_input` rejects, its `after_tool` withholds).
- **The same rules as Rust handlers**: one registry and one name space for
every language, registration order, fail closed on errors and timeouts.
- **An abandoned JavaScript hook keeps running.** When the bridge stops
waiting for a hook — its timeout passed, the run was cancelled
(`Agent::abort()`), its plugin unloaded — the answer is discarded, but a
JavaScript function is not stopped: no `AbortSignal` is passed, so it runs
to completion. That includes **`call_tool` side effects after a run was
cancelled** (a write, a request, a payment): make tools idempotent, or check
your own deadline before acting. A Python coroutine is cancelled
(`asyncio.CancelledError` at its next `await`). (rutis-bridge 0.7 can pass
an `AbortSignal`, but only as an extra positional argument, which a Python
method with a fixed signature would refuse; the bridge cannot tell the two
apart, so it passes none.)
- **Every hook gets a cancel handle: `signal`.** Each hook but `on_event`
finds it in its first argument (`call.signal`, `turn.signal`, ...): a real
`AbortSignal` in JavaScript, rutis's `Signal` in Python
(`signal.cancelled`, `await signal.wait()`). It is aborted when the bridge
stops waiting — the run was cancelled (`Agent::abort()`), the hook's
timeout passed, its plugin unloaded mid-call — and never once the call
completed. The answer of an abandoned call is discarded, but a JavaScript
function that ignores its signal runs to completion, side effects and all:
**pass `call.signal` on** (`fetch(url, { signal })`, a child process, a dsh
tool's `execute`) or check `signal.aborted` before acting. A Python
coroutine is also cancelled (`asyncio.CancelledError` at its next
`await`). The handle is a field rather than an extra positional argument
so that a Python method with a fixed signature still accepts the call;
it is the one value that is not plain data (`json.dumps(call)` refuses
it — drop `signal` first).

```ts
async call_tool(call) {
const res = await fetch(call.args.url, { signal: call.signal })
return await res.text()
}
```
- **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 @@ -242,6 +253,63 @@ 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

### dsh (DeepSeek Harness) tool plugins

dsh plugins are Cordis plugins, and rutis's Node runtime runs them
unchanged. [`plugins/dsh/dsh-tools-adapter.ts`](plugins/dsh/dsh-tools-adapter.ts)
offers every tool in dsh's tool registry (the `tools` service of
`@deepseek-ai/dsh-tools`) to yoagent agents:

| Hook | What the adapter does |
|---|---|
| `tools` | `tools.schemas()` → name, description, parameters (config `tools`: an allowlist) |
| `call_tool` | `tools.execute({callId, name, arguments, signal})` with the bridge's cancel handle as dsh's `signal`: cancelling the run aborts the dsh call. `isError` → an error tool result; text blocks joined |
| `before_model` | the system-prompt sections dsh plugins added (the harness identity and persona slots left out, sections whose variables are unset skipped), as one note, capped at `maxNoteChars` (2000) |

Load, as rows of one Node runtime whose `package.json` is `plugins/dsh/`'s:
`@deepseek-ai/dsh-system-prompt`, `@deepseek-ai/dsh-tools`, your dsh tool
plugins (and what they need, e.g. `@deepseek-ai/dsh-web`), then the adapter;
share `yoagent` in the loader's catalog. `plugins/dsh/package.json` pins
everything exactly (dsh 0.2.0-rc.2 is a release candidate) and is its own
install, so the language tests' stays small.

```sh
(cd plugins/dsh && npm ci)
cargo run --features node --example dsh_tools # scripted model, real web search: needs network
cargo run --features node --example dsh_tools -- --live # DeepSeek: DEEPSEEK_API_KEY or ~/.dskey
```

[`examples/dsh_tools.rs`](examples/dsh_tools.rs) loads `dsh-web`,
`dsh-system-prompt`, `dsh-tools`, the unchanged `dsh-free-search` plugin and
the adapter, runs one search, and checks that a dsh tool answered (and,
scripted, that free-search's prompt section reached the model).
`tests/dsh_test.rs` covers the adapter offline with a fixture dsh plugin
(`plugins/dsh/fixture-tools.ts`).

### rutis-agent tools

[rutis-agent](https://github.com/arcships/rutis/tree/v0.7.0/crates/rutis-agent)
keeps its tools in a `ToolRegistry` service.
[`examples/rutis-agent-tools/`](examples/rutis-agent-tools/src/main.rs) is a
Rust rutis plugin that maps every `ToolDef` to a yoagent tool per run
(results as text, failures as `ToolError`s, yoagent's cancel token passed as
rutis-agent's), shown with rutis-agent's `replace_text` and a tool registered
into the registry while the host runs, which the next run offers.

```sh
cargo run --manifest-path examples/rutis-agent-tools/Cargo.toml [-- --live]
```

> **Pinning caveat.** rutis matches services by Rust type, so rutis-agent and
> this bridge must share one `rutis` crate. crates.io's `rutis-agent` 0.2.0 is
> built on rutis 0.2; the one on rutis 0.6 (the rutis repository at tag
> v0.7.0) is unpublished. The example is therefore its own workspace: it takes
> rutis-agent from git at that tag and patches crates.io's `rutis` to the same
> tag (`[patch.crates-io]`), leaving one `rutis` in the graph (`cargo tree -d`
> shows none twice). Move the tag and the patch together.

## Semantics

### Runs and plugin lifecycles
Expand Down
Loading
Loading