diff --git a/CHANGELOG.md b/CHANGELOG.md index b7dc029..ed76563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to `yoagent` are documented here. The format loosely follows [Keep a Changelog](https://keepachangelog.com/), and the project adheres to [Semantic Versioning](https://semver.org/). -## Unreleased +## 0.25.0 (2026-10-08) ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 86cf1d7..fa62093 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,11 +96,11 @@ Bedrock is the one non-SSE provider: ConverseStream answers with binary `applica ### Workers bindings (`integrations/yoagent-workers/`, separate crate) -Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoagent extension points. wasm32-only: every dependency is under `[target.'cfg(target_arch = "wasm32")']`, `lib.rs` gates its modules, and on other targets the crate is empty. Not a workspace member; `yoagent` by path + version with `default-features = false, features = ["decision"]`; Published on crates.io (0.1.0, 2026-10-04); its next release requires yoagent ≥ 0.25 (`prices::global::pricing_enabled` / opt-in pricing; 0.1.0 required 0.24 for `decision::parse_systemone_response`) — at the 0.25 release bump its `yoagent` requirement to the path's version and its own version to 0.2.0. No `worker` crate dependency: bindings are taken as `impl Into` (workers-rs's `Ai` converts). `ai`: `AiBackend` (`DecisionBackend`, `async_trait(?Send)`) serializes the `Request` to a JS object, calls `env.AI.run(model_path, input)` (a sync throw or a rejection is read for a leading `NNNN:` Workers AI code, after an optional `Name: `: 3040 → `RateLimited`, retried with `with_retry`'s `RetryConfig` via `yoagent::rt::sleep`; 3036 → `Http` 429; else `Backend` with the JS name + message; no `run` → `Invalid`; non-JSON output → `BadResponse`), then `parse_systemone_response`; `clef(ai)` / `clef_flash(ai)` = `from_backend(..).with_cost(prices cloudflare/)` resolved at construction (unpriced unless the Worker called `prices::enable_bundled()` first; warns only when prices are enabled but the id is missing). A binding belongs to one request: models and agents are built inside the handler. Tests: `tests/node.rs` (wasm-bindgen-test under Node, fake bindings built with `Function::new_with_args`). Example Worker `examples/clef-worker/` (own `[workspace]`, path deps, `cdylib`; not auto-discovered by the crate and not packaged): DeepSeek agent + two note tools (one note carries an injected instruction) + `ToolGate` over Clef (`AiBackend` wrapped in a `Timed` `DecisionBackend` that records each call's ms), secrets `DEEPSEEK_API_KEY` / `RUN_TOKEN` (empty = 500); response classifies tool outcomes (`ok`/`denied`/`gate_unavailable`/`failed`, by the `Tool call denied:` and gate-unavailable text prefixes) and reads the run's end from `AgentEnd` (502 on Error/Aborted, 422 on Refusal, `stopped` on `AGENT_STOPPED_PREFIX`). Managed with the Cloudflare CLI `cf` (beta): `cloudflare.config.ts` (entrypoint `build/index.js`, AI binding `dev.remote`, `bindings.secret()`s) + `wrangler.config.ts` (the `worker-build@^0.8` build command; cf delegates Rust builds to Wrangler ≥ 4.136 from `package.json`); no `wrangler.toml`. CI type-checks and lints it (fmt, wasm32 clippy) but never builds it with worker-build or runs it. Run live once (2026-10-04, `cf dev`, DeepSeek + real `env.AI`): Clef allowed `list_notes` and a requested `delete_note`; the bad-key path gave 502; no live denial observed (DeepSeek ignored the injected note). `cf build` / `cf deploy` (beta.12) write `.cloudflare/output/v0/` but do not exit — deploy with `cf deploy --prebuilt --secrets-file `, delete with `cf workers delete --force`; `cf dev` works. `?gate=jev` swaps the gate to Jev (`SystemOneBackend::typesafe().with_api_key(TYPESAFE_API_KEY)`), timed by the same `Timed` wrapper (`timing: {gate, total_ms, gate_ms}`). Edge test 2026-10-04 (deployed, then deleted): Clef median 308 ms, Jev 230 ms per gate check. A `.dev.vars` for local runs is git-ignored — delete it after. In a Worker prefer `Agent::from_provider` (one provider, no decision model: ~328 KiB gzipped) over `from_config` (all seven, ~470 KiB). CI job `workers-bindings` (fmt, wasm32 clippy with an absolute `CLIPPY_CONF_DIR` — a relative one resolves against the nested package —, wasm32 docs, native check + docs of the empty crate, Node tests). `rust-version` mirrors yoagent's but is not CI-checked. Locally: a rustup toolchain with the wasm32 target first on PATH and a `wasm-bindgen-test-runner` matching the resolved `wasm-bindgen`; run with `--manifest-path integrations/yoagent-workers/Cargo.toml --target wasm32-unknown-unknown`. +Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoagent extension points. wasm32-only: every dependency is under `[target.'cfg(target_arch = "wasm32")']`, `lib.rs` gates its modules, and on other targets the crate is empty. Not a workspace member; `yoagent` by path + version with `default-features = false, features = ["decision"]`; Published on crates.io: 0.1.0 (2026-10-04) for yoagent 0.24 (`decision::parse_systemone_response`), 0.2.0 for yoagent 0.25 (`prices::global::pricing_enabled` / opt-in pricing); keep its `yoagent` requirement at the path's version when releasing. No `worker` crate dependency: bindings are taken as `impl Into` (workers-rs's `Ai` converts). `ai`: `AiBackend` (`DecisionBackend`, `async_trait(?Send)`) serializes the `Request` to a JS object, calls `env.AI.run(model_path, input)` (a sync throw or a rejection is read for a leading `NNNN:` Workers AI code, after an optional `Name: `: 3040 → `RateLimited`, retried with `with_retry`'s `RetryConfig` via `yoagent::rt::sleep`; 3036 → `Http` 429; else `Backend` with the JS name + message; no `run` → `Invalid`; non-JSON output → `BadResponse`), then `parse_systemone_response`; `clef(ai)` / `clef_flash(ai)` = `from_backend(..).with_cost(prices cloudflare/)` resolved at construction (unpriced unless the Worker called `prices::enable_bundled()` first; warns only when prices are enabled but the id is missing). A binding belongs to one request: models and agents are built inside the handler. Tests: `tests/node.rs` (wasm-bindgen-test under Node, fake bindings built with `Function::new_with_args`). Example Worker `examples/clef-worker/` (own `[workspace]`, path deps, `cdylib`; not auto-discovered by the crate and not packaged): DeepSeek agent + two note tools (one note carries an injected instruction) + `ToolGate` over Clef (`AiBackend` wrapped in a `Timed` `DecisionBackend` that records each call's ms), secrets `DEEPSEEK_API_KEY` / `RUN_TOKEN` (empty = 500); response classifies tool outcomes (`ok`/`denied`/`gate_unavailable`/`failed`, by the `Tool call denied:` and gate-unavailable text prefixes) and reads the run's end from `AgentEnd` (502 on Error/Aborted, 422 on Refusal, `stopped` on `AGENT_STOPPED_PREFIX`). Managed with the Cloudflare CLI `cf` (beta): `cloudflare.config.ts` (entrypoint `build/index.js`, AI binding `dev.remote`, `bindings.secret()`s) + `wrangler.config.ts` (the `worker-build@^0.8` build command; cf delegates Rust builds to Wrangler ≥ 4.136 from `package.json`); no `wrangler.toml`. CI type-checks and lints it (fmt, wasm32 clippy) but never builds it with worker-build or runs it. Run live once (2026-10-04, `cf dev`, DeepSeek + real `env.AI`): Clef allowed `list_notes` and a requested `delete_note`; the bad-key path gave 502; no live denial observed (DeepSeek ignored the injected note). `cf build` / `cf deploy` (beta.12) write `.cloudflare/output/v0/` but do not exit — deploy with `cf deploy --prebuilt --secrets-file `, delete with `cf workers delete --force`; `cf dev` works. `?gate=jev` swaps the gate to Jev (`SystemOneBackend::typesafe().with_api_key(TYPESAFE_API_KEY)`), timed by the same `Timed` wrapper (`timing: {gate, total_ms, gate_ms}`). Edge test 2026-10-04 (deployed, then deleted): Clef median 308 ms, Jev 230 ms per gate check. A `.dev.vars` for local runs is git-ignored — delete it after. In a Worker prefer `Agent::from_provider` (one provider, no decision model: ~328 KiB gzipped) over `from_config` (all seven, ~470 KiB). CI job `workers-bindings` (fmt, wasm32 clippy with an absolute `CLIPPY_CONF_DIR` — a relative one resolves against the nested package —, wasm32 docs, native check + docs of the empty crate, Node tests). `rust-version` mirrors yoagent's but is not CI-checked. Locally: a rustup toolchain with the wasm32 target first on PATH and a `wasm-bindgen-test-runner` matching the resolved `wasm-bindgen`; run with `--manifest-path integrations/yoagent-workers/Cargo.toml --target wasm32-unknown-unknown`. ### 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`). `publish = false` until yoagent 0.25 (`Extension`) ships. Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Python plugins** (`languages.rs`, features `node`/`python`/`websocket` → optional `rutis-bridge = "0.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit), `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7 allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.7.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY` / `~/.dskey`; self-checks). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.7.0), `requirements.txt` (`rutis==0.7.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 24 / `plugins/node_modules` / Python with rutis 0.7 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.86, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 24, Python 3.12: `npm ci` in `plugins/` and `plugins/dsh/`, clippy per feature, docs, tests with the runtimes required, the `language_plugins` example; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. +Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.6"` (0.x caret = 0.6.x; any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Python plugins** (`languages.rs`, features `node`/`python`/`websocket` → optional `rutis-bridge = "0.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync}`). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit), `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7 allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.7.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY` / `~/.dskey`; self-checks). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered, not in CI): rutis-agent from git (`arcships/rutis` tag v0.7.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.7.0), `requirements.txt` (`rutis==0.7.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 24 / `plugins/node_modules` / Python with rutis 0.7 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.86, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 24, Python 3.12: `npm ci` in `plugins/` and `plugins/dsh/`, clippy per feature, docs, tests with the runtimes required, the `language_plugins` example; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/Cargo.toml b/Cargo.toml index 4824dfb..fb593dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "yoagent" -version = "0.24.3" +version = "0.25.0" edition = "2021" # MSRV-aware dependency resolution (cargo 1.84+): prefer dependency versions # whose own rust-version fits ours. Without it CI re-resolves to whatever is diff --git a/README.md b/README.md index 063f041..3868b6d 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ cargo run --example cli -- --api-url http://localhost:1234/v1 --model my-model ```toml [dependencies] -yoagent = "0.24" +yoagent = "0.25" tokio = { version = "1", features = ["full"] } ``` diff --git a/integrations/yoagent-rutis/Cargo.toml b/integrations/yoagent-rutis/Cargo.toml index 706e31d..4583c35 100644 --- a/integrations/yoagent-rutis/Cargo.toml +++ b/integrations/yoagent-rutis/Cargo.toml @@ -21,13 +21,11 @@ exclude = [ ] # 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. -publish = false [dependencies] -# The path's version. Publishing needs the first yoagent release with -# `Extension` (0.25): raise this to it then. -yoagent = { path = "../..", version = "0.24.3" } +# The path's version (0.25 is the first release with `Extension`). yoagent +# types are in this crate's API: a yoagent minor bump is a minor bump here. +yoagent = { path = "../..", version = "0.25.0" } # 0.x caret: 0.6.x only (0.7 would be breaking). Any rutis minor bump is a # yoagent-rutis minor bump — its types are part of this crate's API. rutis = "0.6" diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index c892e94..975b21b 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -393,18 +393,17 @@ the others). `Ctx::root_with_sink` — bus listener failures and cleanup errors are reported there, not to the agent. -## Publishing +## Versions -Not yet on crates.io (`publish = false`). It needs the first yoagent release -with `Extension` (0.25); raise the `yoagent` requirement to it and flip -`publish`. yoagent types are in its API too, so a yoagent minor bump is a -yoagent-rutis minor bump. Until then, depend on it by git, and take yoagent -from the same git source: a crates.io `yoagent` next to a git `yoagent-rutis` -is two `yoagent` crates whose types do not match. +yoagent-rutis 0.1 requires yoagent 0.25 (the first release with `Extension`) +and rutis 0.6. yoagent and rutis types are in its API, so a minor bump of +either is a minor bump of yoagent-rutis. Take `yoagent` from the same source +as `yoagent-rutis`: a crates.io `yoagent` next to a git `yoagent-rutis` is two +`yoagent` crates whose types do not match. ```toml -yoagent = { git = "https://github.com/yologdev/yoagent" } -yoagent-rutis = { git = "https://github.com/yologdev/yoagent" } +yoagent = "0.25" +yoagent-rutis = "0.1" ``` ## License diff --git a/integrations/yoagent-workers/Cargo.toml b/integrations/yoagent-workers/Cargo.toml index 8772e0f..374d189 100644 --- a/integrations/yoagent-workers/Cargo.toml +++ b/integrations/yoagent-workers/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "yoagent-workers" -version = "0.1.0" +version = "0.2.0" edition = "2021" resolver = "3" description = "Run yoagent on Cloudflare Workers through the Worker's own bindings: Workers AI decision models (Clef) via env.AI" @@ -21,8 +21,8 @@ targets = ["wasm32-unknown-unknown"] # Everything here exists only on wasm32: a binding is a JavaScript object the # Workers runtime hands the Worker. On other targets the crate is empty. [target.'cfg(target_arch = "wasm32")'.dependencies] -# 0.24.0 is the first release with `decision::parse_systemone_response`. -yoagent = { path = "../..", version = "0.24.0", default-features = false, features = ["decision"] } +# 0.25.0: pricing is opt-in (`prices::global::pricing_enabled`). +yoagent = { path = "../..", version = "0.25.0", default-features = false, features = ["decision"] } async-trait = "0.1" js-sys = "0.3" serde_json = "1" diff --git a/integrations/yoagent-workers/README.md b/integrations/yoagent-workers/README.md index e662b6c..39a5b22 100644 --- a/integrations/yoagent-workers/README.md +++ b/integrations/yoagent-workers/README.md @@ -3,9 +3,9 @@ Run [yoagent](https://crates.io/crates/yoagent) on Cloudflare Workers through the Worker's own bindings. -Requires yoagent 0.25 or later (from the next release of this crate: it -reads `prices::global::pricing_enabled`, and pricing is opt-in from yoagent -0.25 — call `yoagent::provider::prices::enable_bundled()` to price Clef). +yoagent-workers 0.2 requires yoagent 0.25 (it reads +`prices::global::pricing_enabled`; pricing is opt-in from yoagent 0.25 — call +`yoagent::provider::prices::enable_bundled()` to price Clef). yoagent-workers 0.1 works with yoagent 0.24. yoagent itself builds for `wasm32-unknown-unknown` (`default-features = false`) @@ -31,7 +31,7 @@ binding = "AI" ```toml # Cargo.toml [dependencies] -yoagent = { version = "0.24", default-features = false, features = ["decision"] } +yoagent = { version = "0.25", default-features = false, features = ["decision"] } yoagent-workers = "0.1" ``` diff --git a/integrations/yoagent-workers/examples/clef-worker/Cargo.toml b/integrations/yoagent-workers/examples/clef-worker/Cargo.toml index 7bd2e46..72c4ce2 100644 --- a/integrations/yoagent-workers/examples/clef-worker/Cargo.toml +++ b/integrations/yoagent-workers/examples/clef-worker/Cargo.toml @@ -9,8 +9,8 @@ description = "A yoagent agent in a Cloudflare Worker, its tool calls gated by C crate-type = ["cdylib"] # Paths so CI checks this against the code beside it. Outside this repo: -# yoagent = { version = "0.24", default-features = false, features = ["decision"] } -# yoagent-workers = "0.1" +# yoagent = { version = "0.25", default-features = false, features = ["decision"] } +# yoagent-workers = "0.2" [dependencies] yoagent = { path = "../../../..", default-features = false, features = ["decision"] } yoagent-workers = { path = "../.." }