From 59b79b0f41ecb5f7f416d3769008c64006658a09 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 02:42:54 +0200 Subject: [PATCH 1/7] feat(rutis): a cancel handle for TypeScript / Python hooks Every language hook but on_event now finds a cancel handle in its first argument, as the field `signal`: an AbortSignal in JavaScript, rutis's Signal in Python. rutis-bridge aborts it when the bridge drops the call (run cancelled, hook timeout, plugin unloaded mid-call), never after the call completed. A field, not an extra positional argument, so fixed- signature Python methods keep working. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .../yoagent-rutis/plugins/yoagent.d.ts | 52 +++-- integrations/yoagent-rutis/src/languages.rs | 88 ++++++- .../yoagent-rutis/tests/languages_test.rs | 214 ++++++++++++++++++ 3 files changed, 330 insertions(+), 24 deletions(-) diff --git a/integrations/yoagent-rutis/plugins/yoagent.d.ts b/integrations/yoagent-rutis/plugins/yoagent.d.ts index 4b61180..a2c0feb 100644 --- a/integrations/yoagent-rutis/plugins/yoagent.d.ts +++ b/integrations/yoagent-rutis/plugins/yoagent.d.ts @@ -62,12 +62,14 @@ export interface Options { * the bridge required). An answer of an unexpected shape counts as failed * too. * - * An abandoned call is not stopped in JavaScript: when the host stops - * waiting (a timeout, the run cancelled, the plugin unloaded) the answer is - * discarded, but no `AbortSignal` is passed, so the function runs to - * completion — `call_tool` included, side effects and all, after the run - * was cancelled. Make tools idempotent, or keep your own deadline. (Python - * coroutines are cancelled.) + * Every hook but `on_event` gets a cancel handle in its first argument: + * `signal` (see {@link Cancellable}). It aborts when the host stops waiting + * — the run cancelled, the hook's timeout passed, the plugin unloaded — and + * never when the call completes. The answer of an abandoned call is + * discarded; a function that ignores `signal` still runs to completion + * (`call_tool` side effects included), so pass it on (`fetch(url, { signal + * })`) or check `signal.aborted` before acting. (Python coroutines are + * cancelled as well.) * * A member named like a hook must be a function (or absent: `undefined`, * `null`, `None`): `register` throws on anything else, so a policy never @@ -78,25 +80,25 @@ export interface Options { */ export interface Handler { /** Tools to offer for a run (asked once, at its start). */ - tools?(run: RunInfo): Promise + tools?(run: RunInfo & Cancellable): Promise /** * Run one of this handler's tools. Required with `tools`. No timeout - * besides the run's cancellation, and a cancelled call keeps running here - * (its result is discarded): see above. + * besides the run's cancellation, which aborts `call.signal`: pass it on + * to whatever the tool waits for. */ - call_tool?(call: ToolCall): Promise + call_tool?(call: ToolCall & Cancellable): Promise /** Judge a tool call: every call of the run, the agent's own tools too. */ - before_tool?(call: ToolCall): Promise + before_tool?(call: ToolCall & Cancellable): Promise /** Edit a call's output before the model, the history and consumers see it (redaction). */ - after_tool?(call: ToolCall, output: ToolOutput): Promise + after_tool?(call: ToolCall & Cancellable, output: ToolOutput): Promise /** Before each model request. */ - before_model?(turn: Turn): Promise + before_model?(turn: Turn & Cancellable): Promise /** Judge a prompted run's input. */ - on_input?(input: Input): Promise + on_input?(input: Input & Cancellable): Promise /** When the model ends its answer (a verifier). */ - on_stop?(stop: Stop): Promise + on_stop?(stop: Stop & Cancellable): Promise /** When the run ends, however it ends. */ - finish?(outcome: Outcome): Promise + finish?(outcome: Outcome & Cancellable): Promise /** * Each event of the types in `options.events`, in order. Delivered * asynchronously: the run never waits for it. Its failure is noticed at the @@ -106,6 +108,24 @@ export interface Handler { on_event?(event: AgentEvent): Promise } +/** + * The cancel handle of one hook call, a field of the hook's first argument + * (so a Python method with a fixed signature still accepts the call). + * + * In JavaScript a real `AbortSignal`: rutis aborts it when the host gives + * the call up — the run cancelled (`Agent::abort()`), the hook's timeout + * passed, or the plugin unloaded mid-call — and never once the call has + * completed. In Python it is rutis's `Signal`: `signal.cancelled` (bool) and + * `await signal.wait()`; the coroutine is also cancelled + * (`asyncio.CancelledError` at its next `await`). + * + * It is the one field that is not plain data: `JSON.stringify` shows it as + * `{}`, and Python's `json.dumps` refuses it (drop it first). + */ +export interface Cancellable { + signal: AbortSignal +} + /** The run a hook is called for. */ export interface RunInfo { /** Unique per run. */ diff --git a/integrations/yoagent-rutis/src/languages.rs b/integrations/yoagent-rutis/src/languages.rs index 220668c..4687853 100644 --- a/integrations/yoagent-rutis/src/languages.rs +++ b/integrations/yoagent-rutis/src/languages.rs @@ -32,12 +32,16 @@ //! plugin's after an unload: its tool calls fail, its `before_tool` denies, //! its `on_input` rejects, its `after_tool` withholds. //! -//! An abandoned call (a timeout, a cancelled run, an unloaded plugin) is -//! dropped on the host side; a Python coroutine is then cancelled, but a -//! JavaScript function runs to completion (no `AbortSignal` is passed: -//! rutis-bridge passes one only as an extra positional argument, which a -//! Python method with a fixed signature would refuse), so a `call_tool` can -//! still act after its run was cancelled. +//! Every hook but `on_event` finds a cancel handle in its first argument, +//! the `signal` field: an `AbortSignal` in JavaScript, a `rutis` `Signal` +//! (`.cancelled`, `await signal.wait()`) in Python. It is aborted when the +//! host gives the call up — the run cancelled (`Agent::abort()`), the hook's +//! timeout passed, the plugin unloaded mid-call — and never when the call +//! completes. A JavaScript function that ignores it runs to completion (its +//! answer is discarded); a Python coroutine is also cancelled +//! (`asyncio.CancelledError` at its next `await`). Pass `call.signal` on +//! (to `fetch`, to a child process) so a `call_tool` stops acting once its +//! run is cancelled. //! //! The hooks, their plain-JSON arguments and the values they return are //! described in `plugins/yoagent.d.ts` in the crate's repository. Every hook @@ -333,8 +337,38 @@ impl Target { /// Call `hook` with JSON arguments; a returned Promise / coroutine is /// awaited. The result must be plain data. + /// + /// The first argument, when it is an object, also carries the call's + /// cancel handle as its `signal` field ([`with_signal`]): dropping this + /// future (a timeout, the run cancelled, the plugin unloaded) cancels the + /// call, which aborts the handle. async fn call(&self, hook: &str, args: Vec) -> Result { - let args = Value::List(args.into_iter().map(Value::Data).collect()); + let args = Value::List( + args.into_iter() + .enumerate() + .map(|(i, arg)| { + if i == 0 { + with_signal(arg) + } else { + Value::Data(arg) + } + }) + .collect(), + ); + self.call_with(hook, args).await + } + + /// Call `hook` with plain JSON arguments and no cancel handle + /// (`on_event`: an event is data the plugin may keep or forward as is). + async fn call_plain(&self, hook: &str, args: Vec) -> Result { + self.call_with( + hook, + Value::List(args.into_iter().map(Value::Data).collect()), + ) + .await + } + + async fn call_with(&self, hook: &str, args: Value) -> Result { let reply = match self { Self::Object(object) => object.call_method_async(hook, args).await, Self::Record(functions) => match functions.get(hook) { @@ -352,6 +386,31 @@ impl Target { } } +/// The field of a hook's first argument that carries the call's cancel +/// handle. +const SIGNAL_FIELD: &str = "signal"; + +/// `arg` with the call's cancel handle as its [`SIGNAL_FIELD`]: a JSON object +/// becomes a record whose fields are copied as data, plus rutis-bridge's +/// call signal — an `AbortSignal` in JavaScript, a `rutis` `Signal` +/// (`.cancelled`, `await .wait()`) in Python. rutis-bridge aborts it when +/// the host gives the call up (its future dropped): the run cancelled, the +/// hook's timeout, the plugin unloaded. It is a field rather than an extra +/// positional argument so that a Python method with a fixed signature still +/// accepts the call. Anything but an object is passed as it is. +fn with_signal(arg: Json) -> Value { + match arg { + Json::Object(fields) => Value::Record( + fields + .into_iter() + .map(|(key, value)| (key, Value::Data(value))) + .chain([(SIGNAL_FIELD.to_string(), Value::Signal)]) + .collect(), + ), + other => Value::Data(other), + } +} + /// A language plugin's handler. struct RemoteHandler { name: String, @@ -535,7 +594,7 @@ impl HandlerImpl for RemoteHandler { let target = self.target.clone(); let deliver: Deliver = Arc::new(move |event| { let target = target.clone(); - Box::pin(async move { target.call("on_event", vec![event]).await }) + Box::pin(async move { target.call_plain("on_event", vec![event]).await }) }); Some(Box::new(RemoteEvents::start( self.name.clone(), @@ -1107,4 +1166,17 @@ mod tests { assert_eq!(delivered.lock().unwrap().len(), 1); assert_eq!(sink.failure(), None); } + + #[test] + fn an_object_argument_carries_the_cancel_handle() { + let Value::Record(fields) = with_signal(json!({"tool": "t", "args": {"a": 1}})) else { + panic!("an object becomes a record"); + }; + assert!(matches!(fields.get(SIGNAL_FIELD), Some(Value::Signal))); + assert!(matches!(fields.get("tool"), Some(Value::Data(t)) if t == "t")); + assert!(matches!(fields.get("args"), Some(Value::Data(a)) if a == &json!({"a": 1}))); + assert_eq!(fields.len(), 3); + // Anything else is passed as it is. + assert!(matches!(with_signal(json!("text")), Value::Data(t) if t == "text")); + } } diff --git a/integrations/yoagent-rutis/tests/languages_test.rs b/integrations/yoagent-rutis/tests/languages_test.rs index 9bb7fed..1aa56e3 100644 --- a/integrations/yoagent-rutis/tests/languages_test.rs +++ b/integrations/yoagent-rutis/tests/languages_test.rs @@ -135,6 +135,20 @@ impl Probe { self.0.lock().unwrap().clone() } + /// The first line `matches`, once one is recorded. + async fn wait_line(&self, matches: impl Fn(&str) -> bool) -> String { + tokio::time::timeout(Duration::from_secs(30), async { + loop { + if let Some(line) = self.lines().into_iter().find(|l| matches(l)) { + return line; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("no such line: {:?}", self.lines())) + } + async fn wait_for(&self, line: &str) { tokio::time::timeout(Duration::from_secs(30), async { while !self.lines().iter().any(|l| l == line) { @@ -499,9 +513,88 @@ export default definePlugin({ }) "#; +/// Hooks that wait on their cancel handle (`signal`), each reporting what +/// it saw; `js_quick` watches its handle after answering (the control). +const JS_CANCEL: &str = r#" +import { definePlugin } from 'RUTIS' +const aborted = signal => new Promise(resolve => { + if (signal.aborted) return resolve() + signal.addEventListener('abort', resolve, { once: true }) + setTimeout(resolve, 60000) +}) +export default definePlugin({ + inject: ['yoagent', 'probe'], + apply(ctx) { + const probe = ctx.use('probe') + const yoagent = ctx.use('yoagent') + ctx.effect(yoagent.register('js-cancel', { + async tools(run) { + probe.record(`js tools: signal ${run.signal instanceof AbortSignal}`) + return [{ name: 'js_wait' }, { name: 'js_quick' }] + }, + async call_tool(call) { + if (call.tool === 'js_quick') { + call.signal.addEventListener('abort', () => probe.record('js quick: aborted')) + return 'quick' + } + probe.record('js wait: started') + await aborted(call.signal) + probe.record(`js wait: aborted=${call.signal.aborted} ${call.signal.reason?.name}`) + return 'waited' + }, + async before_tool(call) { + if (call.tool !== 'slow_gate') return + await aborted(call.signal) + probe.record(`js gate: aborted=${call.signal.aborted}`) + }, + })) + }, +}) +"#; + +/// The same in Python: a coroutine waiting on `signal.wait()` is cancelled +/// too, after the handle was set. +const PY_CANCEL: &str = r#" +import asyncio + +inject = ["yoagent", "probe"] + + +class Handler: + def __init__(self, probe): + self.probe = probe + self.watchers = set() + + async def tools(self, run): + return [{"name": "py_wait"}, {"name": "py_quick"}] + + async def call_tool(self, call): + signal = call["signal"] + if call["tool"] == "py_quick": + async def watch(): + await signal.wait() + self.probe.record("py quick: cancelled") + task = asyncio.get_running_loop().create_task(watch()) + self.watchers.add(task) + return "quick" + self.probe.record(f"py wait: started cancelled={signal.cancelled}") + try: + await signal.wait() + self.probe.record(f"py wait: woke cancelled={signal.cancelled}") + except asyncio.CancelledError: + self.probe.record(f"py wait: CancelledError cancelled={signal.cancelled}") + raise + return "waited" + + +def apply(ctx, config): + ctx.effect(ctx.use("yoagent").register("py-cancel", Handler(ctx.use("probe")))) +"#; + struct Fixtures { js: PathBuf, strict: PathBuf, + cancel: PathBuf, py: PathBuf, _dir: tempfile::TempDir, } @@ -517,12 +610,16 @@ fn fixtures() -> Fixtures { std::fs::write(&js, JS_HOOKS.replace("RUTIS", &rutis)).unwrap(); let strict = dir.path().join("strict.mjs"); std::fs::write(&strict, JS_STRICT.replace("RUTIS", &rutis)).unwrap(); + let cancel = dir.path().join("cancel.mjs"); + std::fs::write(&cancel, JS_CANCEL.replace("RUTIS", &rutis)).unwrap(); let py = dir.path().join("py"); std::fs::create_dir_all(&py).unwrap(); std::fs::write(py.join("py_hooks.py"), PY_HOOKS).unwrap(); + std::fs::write(py.join("py_cancel.py"), PY_CANCEL).unwrap(); Fixtures { js, strict, + cancel, py, _dir: dir, } @@ -1007,3 +1104,120 @@ async fn register_and_results_are_strict() { ); host.root.shutdown().await.unwrap(); } + +// ── Cancel handles (`signal`) ─────────────────────────────────── + +/// Start a run, wait until the plugin records `started`, cancel the run +/// (`Agent::abort`) and let it end. +async fn cancel_once_started(probe: &Probe, agent: &mut yoagent::Agent, started: &str) { + let mut rx = agent.prompt("go").await; + probe.wait_for(started).await; + agent.abort(); + tokio::time::timeout(Duration::from_secs(30), async { + while rx.recv().await.is_some() {} + }) + .await + .expect("the cancelled run ends"); + agent.finish().await; +} + +/// How long a control waits for an abort that must not come. +const QUIET: Duration = Duration::from_millis(500); + +#[tokio::test(flavor = "multi_thread")] +async fn a_typescript_hook_sees_its_signal_abort_on_cancel_and_timeout() { + let Some(node) = node_runtime() else { + return; + }; + let fx = fixtures(); + let host = host(Some(&node), None).await; + host.load(vec![ts_row("cancel", &fx.cancel, json!({}))]) + .await; + host.until_handlers(&["js-cancel"]).await; + + let (quick, _) = agent(vec![ + call("js_quick", json!({})), + text("done"), + call("js_wait", json!({})), + text("never sent"), + ]); + let mut quick = quick.with_extension(host.bridge.extension()); + // Control: a call that completes keeps its handle un-aborted, also + // after its run ended (checked at the end). + let (_, results) = run(&mut quick, "quick").await; + assert_eq!(results, vec![("js_quick".into(), "quick".into(), false)]); + host.probe.wait_for("js tools: signal true").await; + + // Cancelling the run aborts the signal of the call in flight. + cancel_once_started(&host.probe, &mut quick, "js wait: started").await; + host.probe + .wait_for("js wait: aborted=true AbortError") + .await; + + // A hook the host stops waiting for (its timeout) is aborted too. + let gate = Reply::new("slow_gate", "ran"); + let gate_runs = gate.runs(); + let (gated, _) = agent(vec![call("slow_gate", json!({})), text("done")]); + let mut gated = gated.with_tools(vec![Box::new(gate)]).with_extension( + host.bridge + .extension() + .with_policy_timeout(Some(Duration::from_millis(300))), + ); + let (_, results) = run(&mut gated, "gate").await; + assert!( + results[0].2 && results[0].1.contains("did not answer"), + "{results:?}" + ); + assert_eq!(gate_runs.load(Ordering::SeqCst), 0); + host.probe.wait_for("js gate: aborted=true").await; + + tokio::time::sleep(QUIET).await; + assert!( + !host.probe.lines().iter().any(|l| l == "js quick: aborted"), + "a completed call is never aborted: {:?}", + host.probe.lines() + ); + host.root.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_python_hook_sees_its_signal_set_when_the_run_is_cancelled() { + let Some(python) = python() else { + return; + }; + let fx = fixtures(); + let host = host(None, Some((&fx.py, &python))).await; + host.load(vec![py_row("py", "py_cancel", json!({}))]).await; + host.until_handlers(&["py-cancel"]).await; + + let (agent, _) = agent(vec![ + call("py_quick", json!({})), + text("done"), + call("py_wait", json!({})), + text("never sent"), + ]); + let mut agent = agent.with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "quick").await; + assert_eq!(results, vec![("py_quick".into(), "quick".into(), false)]); + + cancel_once_started(&host.probe, &mut agent, "py wait: started cancelled=False").await; + // The handle is set before the coroutine is cancelled: whether the wait + // wakes or raises `CancelledError` first, it reads as set. + let line = host + .probe + .wait_line(|l| l.starts_with("py wait: ") && !l.contains("started")) + .await; + assert!(line.ends_with("cancelled=True"), "{line}"); + + tokio::time::sleep(QUIET).await; + assert!( + !host + .probe + .lines() + .iter() + .any(|l| l == "py quick: cancelled"), + "a completed call is never cancelled: {:?}", + host.probe.lines() + ); + host.root.shutdown().await.unwrap(); +} From 81f68cc4b33172b724bc90ab67aaece80bb5e3c1 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 02:42:54 +0200 Subject: [PATCH 2/7] feat(rutis): dsh tools adapter, its example and end-to-end test plugins/dsh/dsh-tools-adapter.ts offers a DeepSeek Harness tool registry to yoagent agents (execute with the bridge's cancel handle as dsh's signal, isError as an error result, dsh plugins' prompt sections as a turn note). examples/dsh_tools.rs runs the unchanged dsh-free-search plugin; tests/dsh_test.rs covers the adapter with a no-network fixture plugin. CI installs plugins/dsh in rutis-bridge-languages. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .github/workflows/ci.yml | 4 + integrations/yoagent-rutis/Cargo.toml | 18 +- .../yoagent-rutis/examples/dsh_tools.rs | 242 +++ .../plugins/dsh/dsh-tools-adapter.ts | 146 ++ .../plugins/dsh/fixture-tools.ts | 81 + .../plugins/dsh/package-lock.json | 1442 +++++++++++++++++ .../yoagent-rutis/plugins/dsh/package.json | 19 + integrations/yoagent-rutis/tests/dsh_test.rs | 240 +++ 8 files changed, 2190 insertions(+), 2 deletions(-) create mode 100644 integrations/yoagent-rutis/examples/dsh_tools.rs create mode 100644 integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts create mode 100644 integrations/yoagent-rutis/plugins/dsh/fixture-tools.ts create mode 100644 integrations/yoagent-rutis/plugins/dsh/package-lock.json create mode 100644 integrations/yoagent-rutis/plugins/dsh/package.json create mode 100644 integrations/yoagent-rutis/tests/dsh_test.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bab993..28a70d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" diff --git a/integrations/yoagent-rutis/Cargo.toml b/integrations/yoagent-rutis/Cargo.toml index 6c6bfbe..706e31d 100644 --- a/integrations/yoagent-rutis/Cargo.toml +++ b/integrations/yoagent-rutis/Cargo.toml @@ -11,8 +11,14 @@ readme = "README.md" keywords = ["agent", "llm", "plugin", "rutis", "yoagent"] categories = ["asynchronous", "development-tools"] authors = ["Yuanhao "] -# 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. @@ -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"] diff --git a/integrations/yoagent-rutis/examples/dsh_tools.rs b/integrations/yoagent-rutis/examples/dsh_tools.rs new file mode 100644 index 0000000..0cbb7ed --- /dev/null +++ b/integrations/yoagent-rutis/examples/dsh_tools.rs @@ -0,0 +1,242 @@ +//! An unchanged dsh (DeepSeek Harness) tool plugin, `dsh-free-search`, as +//! tools of a yoagent agent: dsh's own packages and the adapter +//! `plugins/dsh/dsh-tools-adapter.ts` run as rutis-loader rows in one Node +//! runtime; the adapter registers dsh's tool registry with the bridge. +//! +//! By default the model is scripted (MockProvider) but the tool is real: +//! **`platform_search` searches the web, so this needs network access.** +//! `--live` asks DeepSeek instead (`DEEPSEEK_API_KEY`, else the key in +//! `~/.dskey`). Either way the example checks its outcome and exits non-zero +//! when the dsh tools were not offered or did not answer. +//! +//! Setup (once): `npm ci` in `plugins/dsh/` (Node 24+). +//! +//! Run: `cargo run --manifest-path integrations/yoagent-rutis/Cargo.toml --features node --example dsh_tools [-- --live]` + +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 tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use yoagent::provider::mock::{MockResponse, MockToolCall}; +use yoagent::provider::{ + MockProvider, ModelConfig, ProviderError, StreamConfig, StreamEvent, StreamProvider, +}; +use yoagent::{Agent, AgentEvent, AgentMessage, Content, Message}; +use yoagent_rutis::RutisBridge; + +type BoxError = Box; + +fn dsh_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins/dsh") +} + +fn text_of(content: &[Content]) -> String { + content + .iter() + .filter_map(|c| match c { + Content::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n") +} + +/// The scripted model, keeping the latest user turn of each request (where +/// the adapter's turn note lands). +struct Scripted { + inner: MockProvider, + last_user: Arc>>, +} + +#[async_trait::async_trait] +impl StreamProvider for Scripted { + async fn stream( + &self, + config: StreamConfig, + tx: mpsc::UnboundedSender, + cancel: CancellationToken, + ) -> Result { + let last = config.messages.iter().rev().find_map(|m| match m { + Message::User { content, .. } => Some(text_of(content)), + _ => None, + }); + self.last_user + .lock() + .unwrap() + .push(last.unwrap_or_default()); + self.inner.stream(config, tx, cancel).await + } +} + +fn deepseek_key() -> Result { + if let Ok(key) = std::env::var("DEEPSEEK_API_KEY") { + if !key.trim().is_empty() { + return Ok(key.trim().to_string()); + } + } + let path = Path::new(&std::env::var("HOME")?).join(".dskey"); + let key = std::fs::read_to_string(&path) + .map_err(|e| format!("--live needs DEEPSEEK_API_KEY or {}: {e}", path.display()))?; + Ok(key.split_whitespace().collect()) +} + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + let live = std::env::args().any(|a| a == "--live"); + let runtime = dsh_dir().join("node_modules/@arcships/rutis-runtime"); + if !runtime.exists() || !dsh_dir().join("node_modules/dsh-free-search").exists() { + return Err(format!("run `npm ci` in {} first", dsh_dir().display()).into()); + } + + let root = Ctx::root()?; + let bridge = RutisBridge::install(&root)?; + + // One Node runtime, its package.json the dsh directory's; `yoagent` + // shared by name so the adapter row can inject it. + let mut catalog = ServiceCatalog::new(); + catalog.register_shared("yoagent"); + let node = LocalRuntime::node(&runtime, dsh_dir().join("package.json")); + let rows = Arc::new(RuntimeResolver::node(node.handle()).with_catalog(&catalog)); + root.plugin(node); + let loader_plugin = LoaderPlugin::new( + Chain::new().with_shared(rows.clone()), + LoaderOptions { + catalog, + ..LoaderOptions::default() + }, + ); + let loader = loader_plugin.handle(); + root.plugin(loader_plugin).await?; + root.plugin(RuntimeRowsPlugin::new(rows)); + + // dsh's own packages, unchanged, then the adapter. + let patches: Vec = serde_json::from_value(json!([{ "insert": [ + { "id": "web", "name": "@deepseek-ai/dsh-web" }, + { "id": "system-prompt", "name": "@deepseek-ai/dsh-system-prompt" }, + { "id": "tools", "name": "@deepseek-ai/dsh-tools" }, + { + "id": "free-search", + "name": "dsh-free-search", + "config": { "provider": "bing", "disabledEngines": [], "bingMarket": "en-US" }, + }, + { "id": "adapter", "name": dsh_dir().join("dsh-tools-adapter.ts") }, + ] }]))?; + let report = loader + .reconcile(vec![Layer::new("app", patches)], None) + .await?; + if !report.failures.is_empty() { + return Err(format!("rows failed to load: {report:?}").into()); + } + tokio::time::timeout(Duration::from_secs(60), async { + while bridge.registry().handlers().is_empty() { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .map_err(|_| "the adapter never registered its handler")?; + + let last_user = Arc::new(Mutex::new(Vec::new())); + let prompt = "Find the yoagent repository on GitHub and say who maintains it. Use one search."; + let mut agent = if live { + Agent::from_config(ModelConfig::deepseek("deepseek-flash", "DeepSeek Flash")) + .with_api_key(deepseek_key()?) + } else { + let script = MockProvider::new(vec![ + MockResponse::ToolCalls(vec![MockToolCall { + provider_metadata: None, + name: "platform_search".into(), + arguments: json!({"query": "yoagent", "platform": "github"}), + }]), + MockResponse::Text("(scripted) The search ran.".into()), + ]); + let scripted = Scripted { + inner: script, + last_user: last_user.clone(), + }; + Agent::from_provider(scripted, ModelConfig::mock()) + } + .with_system_prompt("You answer questions with the search tools you have. Be brief.") + .with_extension(bridge.extension()); + + // The agent has no tools of its own: every tool call is a dsh tool's. + let (tx, mut rx) = mpsc::unbounded_channel(); + let printer = tokio::spawn(async move { + while let Some(event) = rx.recv().await { + if let AgentEvent::ToolExecutionStart { + tool_name, args, .. + } = &event + { + println!("> {tool_name} {args}"); + } + } + }); + tokio::time::timeout( + Duration::from_secs(180), + agent.prompt_with_sender(prompt, tx), + ) + .await + .map_err(|_| "the run did not finish within 180 s")?; + printer.await?; + + // What happened, and the checks. + let mut dsh_ok = 0; + let mut answer = String::new(); + for message in agent.messages() { + match message { + AgentMessage::Llm(Message::ToolResult { + tool_name, + content, + is_error, + .. + }) => { + let text = text_of(content); + let shown: String = text.chars().take(400).collect(); + let mark = if *is_error { "error" } else { "ok" }; + println!("[{tool_name}: {mark}] {shown}"); + if !*is_error && !text.trim().is_empty() { + dsh_ok += 1; + } + } + AgentMessage::Llm(Message::Assistant { content, .. }) => { + let text = text_of(content); + if !text.trim().is_empty() { + answer = text; + } + } + _ => {} + } + } + println!("assistant: {answer}"); + let _ = tokio::time::timeout(Duration::from_secs(10), root.shutdown()).await; + + if dsh_ok == 0 { + return Err("no dsh tool call succeeded (is the network up?)".into()); + } + if answer.trim().is_empty() { + return Err("the model gave no answer".into()); + } + if !live { + // The adapter's note carried dsh-free-search's prompt section. + let first = last_user + .lock() + .unwrap() + .first() + .cloned() + .unwrap_or_default(); + if !first.contains("[Guidance from dsh plugins]") || !first.contains("free-search") { + return Err(format!("the dsh prompt note did not reach the model: {first}").into()); + } + println!("note: {} chars from dsh plugins", first.len()); + } + println!("ok: {dsh_ok} dsh tool call(s) answered"); + Ok(()) +} diff --git a/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts b/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts new file mode 100644 index 0000000..8da7356 --- /dev/null +++ b/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts @@ -0,0 +1,146 @@ +// dsh tools as yoagent tools: every tool in a DeepSeek Harness (dsh) tool +// registry — the `tools` service of `@deepseek-ai/dsh-tools` — offered to +// yoagent agents through the yoagent-rutis bridge, plus a short turn note +// carrying the system-prompt sections dsh plugins wrote. +// +// dsh plugins are Cordis plugins; rutis's Node runtime runs them unchanged +// in its Cordis context. Load, as rutis-loader rows of one Node runtime +// whose package.json is this directory's: +// +// @deepseek-ai/dsh-system-prompt (the `systemPrompt` service) +// @deepseek-ai/dsh-tools (the `tools` service) +// (e.g. dsh-free-search, with @deepseek-ai/dsh-web) +// (the adapter) +// +// with `yoagent` shared in the loader's catalog. `examples/dsh_tools.rs` is a +// complete host. +// +// What it does, per hook: +// tools `tools.schemas()` (the global view) → {name, description, parameters} +// call_tool `tools.execute({callId, name, arguments, signal})`, with the +// bridge's cancel handle as dsh's `signal`: cancelling the +// yoagent run aborts the dsh call. `isError` → an error tool +// result; text blocks are joined, other blocks named. +// before_model the sections dsh plugins added to `systemPrompt` (the +// harness identity and persona slots left out), rendered and +// capped, as a note on the request's latest user turn. + +import { definePlugin } from '@arcships/rutis' +import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import type { Cancellable, ToolCall, ToolResult, ToolSpec, Yoagent } from '../yoagent.d.ts' + +/** The slice of `@deepseek-ai/dsh-tools`' `ToolRuntime` this adapter uses. */ +interface DshTools { + schemas(): { name: string; description?: string; parameters?: Record }[] + execute(exec: { + callId: string + name: string + arguments: unknown + signal: AbortSignal + }): Promise<{ + isError: boolean + content?: { type: string; text?: string }[] + error?: { message?: string; info?: { code?: string } } + }> +} + +/** The slice of `@deepseek-ai/dsh-system-prompt`'s `SystemPrompt` this adapter uses. */ +interface DshSystemPrompt { + assemble(context?: { signal?: AbortSignal }): Promise<{ + sections: { name: string; text: string; interpolate?: boolean }[] + contexts: unknown[] + tools: unknown[] + variables: Record + }> +} + +export interface Config { + /** The handler's name in the bridge (unique across the host's plugins). */ + name?: string + /** Offer only these dsh tools (default: every tool in the registry). */ + tools?: string[] + /** Cap on the turn note's length, in characters (0: no note). */ + maxNoteChars?: number +} + +/** Sections `dsh-system-prompt` registers itself: the harness's, not a plugin's. */ +const OWN_SECTIONS = new Set([ + 'harness:identity', + 'deployment:persona-prefix', + 'deployment:persona-suffix', +]) + +const EMPTY_SCHEMA = { type: 'object', properties: {} } + +export default definePlugin({ + inject: ['tools', 'systemPrompt', 'yoagent'], + config: { + type: 'object', + properties: { + name: { type: 'string' }, + tools: { type: 'array', items: { type: 'string' } }, + maxNoteChars: { type: 'integer', minimum: 0 }, + }, + }, + apply(ctx, config) { + const dsh = ctx.use('tools') + const prompt = ctx.use('systemPrompt') + const yoagent = ctx.use('yoagent') + const only = config?.tools ? new Set(config.tools) : undefined + const maxNote = config?.maxNoteChars ?? 2000 + + ctx.effect( + yoagent.register(config?.name ?? 'dsh-tools', { + async tools(): Promise { + return dsh + .schemas() + .filter((schema) => !only || only.has(schema.name)) + .map((schema) => ({ + name: schema.name, + description: schema.description ?? '', + parameters: schema.parameters ?? EMPTY_SCHEMA, + })) + }, + + async call_tool(call: ToolCall & Cancellable): Promise { + const out = await dsh.execute({ + callId: `yoagent:${call.call_id}`, + name: call.tool, + arguments: call.args, + signal: call.signal, + }) + const text = (out.content ?? []) + .map((block) => (block.type === 'text' ? (block.text ?? '') : `[${block.type} block]`)) + .join('\n') + if (out.isError) { + return { text: text || out.error?.message || 'the dsh tool failed', is_error: true } + } + return { text } + }, + + async before_model(turn: Cancellable) { + if (maxNote === 0) return + const assembly = await prompt.assemble({ signal: turn.signal }) + const parts: string[] = [] + for (const section of assembly.sections) { + if (OWN_SECTIONS.has(section.name)) continue + let text: string + try { + // One section at a time: a section whose variables are not + // set here (the dsh loop supplies `model`, `cwd`) is skipped, + // not the whole note. + text = renderPrompt({ ...assembly, sections: [section], contexts: [], tools: [] }) + } catch { + continue + } + if (text.trim()) parts.push(text.trim()) + } + if (parts.length === 0) return + let note = `[Guidance from dsh plugins]\n${parts.join('\n\n')}` + if (note.length > maxNote) note = `${note.slice(0, Math.max(0, maxNote - 1))}…` + return { note } + }, + }), + ) + }, +}) diff --git a/integrations/yoagent-rutis/plugins/dsh/fixture-tools.ts b/integrations/yoagent-rutis/plugins/dsh/fixture-tools.ts new file mode 100644 index 0000000..60be07c --- /dev/null +++ b/integrations/yoagent-rutis/plugins/dsh/fixture-tools.ts @@ -0,0 +1,81 @@ +// A test fixture: a tiny dsh tool plugin (a plain Cordis plugin, as dsh +// plugins are) built with `@deepseek-ai/dsh-tools`' `defineTool`, with no +// network. The end-to-end test `tests/dsh_test.rs` loads it next to +// `dsh-tools-adapter.ts`. Not for production use. +// +// fixture_echo {text} → "echo: " +// fixture_fail {why} → throws: dsh reports an `isError` result +// fixture_slow {} → writes "started" to `config.abortFile`, waits for +// `exec.signal` to abort (60 s at most), then +// writes how it ended there +// +// It also adds a system-prompt section, `fixture:guidance`. + +import { writeFileSync } from 'node:fs' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'yoagent-dsh-fixture' +export const inject = ['tools', 'systemPrompt'] + +export interface Config { + /** Where `fixture_slow` reports how it ended. */ + abortFile?: string +} + +const TEXT = { type: 'string' } as const +const TEXT_OUTPUT = { + schema: TEXT, + render: (_args: unknown, value: string) => [{ type: 'text' as const, text: value }], +} + +// The Cordis context is used untyped: Cordis types are not needed at runtime. +export function apply(ctx: any, config: Config | undefined) { + const tools = [ + defineTool({ + name: 'fixture_echo', + description: 'Echo a text back.', + parameters: { text: { type: 'string', description: 'The text to echo.', required: true } }, + output: TEXT_OUTPUT, + async execute(args: { text: string }) { + return `echo: ${args.text}` + }, + }), + defineTool({ + name: 'fixture_fail', + description: 'Always fails, with the reason given.', + parameters: { why: { type: 'string', required: true } }, + output: TEXT_OUTPUT, + async execute(args: { why: string }): Promise { + throw new Error(`fixture failure: ${args.why}`) + }, + }), + defineTool({ + name: 'fixture_slow', + description: 'Waits until it is cancelled.', + parameters: {}, + output: TEXT_OUTPUT, + async execute(_args: unknown, exec: { signal: AbortSignal }) { + const { signal } = exec + if (config?.abortFile) writeFileSync(config.abortFile, 'started') + await new Promise((resolve) => { + if (signal.aborted) return resolve() + signal.addEventListener('abort', () => resolve(), { once: true }) + setTimeout(resolve, 60_000) + }) + const how = signal.aborted ? `aborted: ${signal.reason?.name ?? signal.reason}` : 'not aborted' + if (config?.abortFile) writeFileSync(config.abortFile, how) + return how + }, + }), + ] + for (const tool of tools) ctx.effect(() => ctx.tools.register(tool), `fixture: ${tool.name}`) + ctx.effect( + () => + ctx.systemPrompt.section({ + name: 'fixture:guidance', + order: 4000, + text: 'Fixture guidance: prefer fixture_echo for echoing.', + }), + 'fixture: prompt section', + ) +} diff --git a/integrations/yoagent-rutis/plugins/dsh/package-lock.json b/integrations/yoagent-rutis/plugins/dsh/package-lock.json new file mode 100644 index 0000000..2f609b8 --- /dev/null +++ b/integrations/yoagent-rutis/plugins/dsh/package-lock.json @@ -0,0 +1,1442 @@ +{ + "name": "yoagent-rutis-dsh", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yoagent-rutis-dsh", + "dependencies": { + "@arcships/rutis": "0.7.0", + "@arcships/rutis-runtime": "0.7.0", + "@deepseek-ai/cordis": "4.0.4", + "@deepseek-ai/dsh-settings": "0.2.0-rc.2", + "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2", + "@deepseek-ai/dsh-tools": "0.2.0-rc.2", + "@deepseek-ai/dsh-web": "0.2.0-rc.2", + "dsh-free-search": "0.8.1" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@arcships/rutis": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@arcships/rutis/-/rutis-0.7.0.tgz", + "integrity": "sha512-unXRYAGJ71PGHNIBWcCUM9WLZh4JJQaiL8lj7kO9M/0+MMJ8NFQHoSmds1jrbnt9jvkTaY54OvC95sYXWTNXPg==", + "license": "MIT", + "engines": { + "node": ">=24" + } + }, + "node_modules/@arcships/rutis-runtime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@arcships/rutis-runtime/-/rutis-runtime-0.7.0.tgz", + "integrity": "sha512-sII2cwbyqu64229b0BCsynyLyYJZQTxj927tNgBW73ih/adDFI9F3MvDpe4+GE9cQ7CRnKZ/i0RCaUMmqgZTbA==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/cordis": "4.0.4", + "tsx": "^4.20.0", + "typescript": "6.0.3", + "ws": "^8.22.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@deepseek-ai/cordis": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis/-/cordis-4.0.4.tgz", + "integrity": "sha512-obgyxqWAmFn3Re8kvsuUnyW+ihrz6eJCnJO4fh1cQzDtmPYz/zzVeUkH9R94I0OwSVOocK67Kgakm04j/oQXzg==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/cosmokit": "~1.8.5", + "@standard-schema/spec": "^1.1.0" + }, + "bin": { + "cordis": "bin.js" + }, + "peerDependencies": { + "@deepseek-ai/cordis-plugin-include": "~1.0.9", + "@deepseek-ai/cordis-plugin-loader": "~1.0.5" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis-plugin-include": { + "optional": true + }, + "@deepseek-ai/cordis-plugin-loader": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/cordis-plugin-group": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis-plugin-group/-/cordis-plugin-group-1.0.4.tgz", + "integrity": "sha512-/7tuY5pMQetHava4v6QUeOa4CwZA+mleNvbIoj+iqEmzkYUtlXbLqhzJIcbnDa/Hxgr1jaCwROrI77nuLyeCFg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/cordis-plugin-loader": "~1.0.5" + } + }, + "node_modules/@deepseek-ai/cordis-plugin-include": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis-plugin-include/-/cordis-plugin-include-1.0.9.tgz", + "integrity": "sha512-Xmekm7u5Kpeym6nABL4T1/Mg6aQYFZxsipB0WycmCctvX1DqAv8nCFwRmg2XMx/f7EbZgNEbXQwJfabcMu8zxQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/cosmokit": "~1.8.5", + "js-yaml": "^4.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/cordis-plugin-loader": "~1.0.5" + } + }, + "node_modules/@deepseek-ai/cordis-plugin-loader": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis-plugin-loader/-/cordis-plugin-loader-1.0.5.tgz", + "integrity": "sha512-200huL2ttWcjyeYllvPYn+qMR7I89qy8MhZasbHltPzB/KMrSfsyoCYKdCGc5KqPGj5iJugcmibQzjINMO9I+Q==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/cosmokit": "~1.8.5", + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "node-addon-require-builtin": "^0.1.6" + }, + "peerDependenciesMeta": { + "node-addon-require-builtin": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/cosmokit": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.5.tgz", + "integrity": "sha512-LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A==", + "license": "MIT" + }, + "node_modules/@deepseek-ai/dsh-agent": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent/-/dsh-agent-0.2.0-rc.2.tgz", + "integrity": "sha512-CUkbU+c1G0ETXkpoLkpAFo5midz66FQGtlgQOP9pzvU9H3zJQVl4H5LrhhGVbEXKLFgdbtQiOynEW8hpWn3y0Q==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-scope": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2", + "@deepseek-ai/dsh-session-projection": "0.2.0-rc.2", + "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2", + "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2", + "@deepseek-ai/dsh-util-values": "0.2.0-rc.2", + "@deepseek-ai/dsh-workspace": "0.2.0-rc.2" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-workspace": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/dsh-app-boot": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-app-boot/-/dsh-app-boot-0.2.0-rc.2.tgz", + "integrity": "sha512-WvgNhBHSj85Z7u9vC1oQr9C7yZQ/L/JS+eVK4bueWEkaoqdEna504V5bi7qtWXxAz5JkeVZTy1sX86p8XG7LJg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/dsh-atomic-write": "0.2.0-rc.2", + "@deepseek-ai/dsh-package-manifest": "0.2.0-rc.2", + "@eslint-community/regexpp": "^4.12.2", + "ajv": "^8.20.0", + "js-yaml": "^4.2.0", + "node-addon-require-builtin": "^0.1.6", + "resolve.exports": "^2.0.3", + "semver": "^7.8.5" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/cordis-plugin-group": "~1.0.4", + "@deepseek-ai/cordis-plugin-include": "~1.0.9", + "@deepseek-ai/cordis-plugin-loader": "~1.0.5", + "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2", + "@deepseek-ai/dsh-launch-environment": "0.2.0-rc.2", + "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-atomic-write": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-atomic-write/-/dsh-atomic-write-0.2.0-rc.2.tgz", + "integrity": "sha512-CezxdPmADDnOyG/ed9R18In/NifnLgu8hfJEjTBKxd7kbu0XXenyqnk25jeWD6bJYwu8AWAwcpVFuf438EJPJA==", + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-brand": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.2.0-rc.2.tgz", + "integrity": "sha512-A5XlN4tIgP0qObkYFhcZ7Oz/tM2oESEZqLwS0Qq+sM1ivGx6o4AMPgJdfKQlwLzqk0cMLNtAaVBHfiYoElt4lw==", + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-config-editor": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-config-editor/-/dsh-config-editor-0.2.0-rc.2.tgz", + "integrity": "sha512-LG/4EyMMBEwcUAsewqn9+6m9+mFeXVGaze0gaz3YOO/bCnJRI/2f4iuejsk/n+yMI9CNczOaIfWT5b3s7zyhfA==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-atomic-write": "0.2.0-rc.2", + "js-yaml": "^4.1.0", + "yaml": "^2.8.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/cordis-plugin-include": "~1.0.9", + "@deepseek-ai/cordis-plugin-loader": "~1.0.5", + "@deepseek-ai/dsh-app-boot": "0.2.0-rc.2", + "@deepseek-ai/dsh-hmr": "0.2.0-rc.2" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-hmr": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/dsh-home-paths": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-home-paths/-/dsh-home-paths-0.2.0-rc.2.tgz", + "integrity": "sha512-n4O5C9pNIVWbBklB5RzSTc3kr9hmUnDlZ6Leqj3TIk+CO593DTAmDGTt0rHMQe8H+1Ur+Jqj9Hqgsug/WRkkvQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-invariants": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-invariants/-/dsh-invariants-0.2.0-rc.2.tgz", + "integrity": "sha512-QOVc0HpRsS8ekvprb45FlxieDvgR/cAMaxW289HASjMoV/U/B54FpbkHuPBw4EnTw07/sPkjx2B/23+YqE0cbg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-launch-environment": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-launch-environment/-/dsh-launch-environment-0.2.0-rc.2.tgz", + "integrity": "sha512-IyhZjJJQ7RVLcKlc4pPPjojOKrr2XCYm7VRpslFq7bvGj3DGt8WMTSCCZUWxCThY2jg6kFF1ujiTGunELfRO6Q==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-llm": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.2.0-rc.2.tgz", + "integrity": "sha512-6QFrQn/h0iNqCfPpgQidnHtLSpA99A7ZND/uYKaGSd/4BGP8KBhuRd0w63e9s4u+hfW8jc3j29WKcNLIOeUfcA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/dsh-brand": "0.2.0-rc.2", + "@deepseek-ai/dsh-timeout": "0.2.0-rc.2", + "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2", + "@deepseek-ai/dsh-util-crypto": "0.2.0-rc.2", + "@deepseek-ai/dsh-util-values": "0.2.0-rc.2", + "@deepseek-ai/schemastery": "~3.18.4", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-package-manifest": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-package-manifest/-/dsh-package-manifest-0.2.0-rc.2.tgz", + "integrity": "sha512-CFycoLxUT4g4w1IGjaHi/IjTzTRgnQ2PQVeHHSWoEppD+0NTZ1rHHW3ofkh74ZgQagoeo7n+91Yjl0rDGuWE3g==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-ptc-runtime": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-ptc-runtime/-/dsh-ptc-runtime-0.2.0-rc.2.tgz", + "integrity": "sha512-HR/3h1VFRcNlAAjCtCAxZ4V3F5pViYicXcLBuYLGjzXWpOhr4zKzeFKh9+UMLjsc9XWIarJMoSvxl662nDV/xQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-sandbox": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox/-/dsh-sandbox-0.2.0-rc.2.tgz", + "integrity": "sha512-luKF7ey6HRZkGhImqLCB5Npt4f4omDRk1r+x5qQNqqdNOuN9LXciKDMNPfnSbHfKCcqSZbH3sK+APOmKzRAhVA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/dsh-util-values": "0.2.0-rc.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-sandbox-policy": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox-policy/-/dsh-sandbox-policy-0.2.0-rc.2.tgz", + "integrity": "sha512-0xXVqWw908/V6scohMyyU9SZSiQJYji+S1hMPulcUNLb3iew89d+WPWMIkWQBJRvcJzdQ2ghHwCESRkONXTJnQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-agent": "0.2.0-rc.2", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2", + "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2", + "@deepseek-ai/dsh-session-projection": "0.2.0-rc.2", + "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-scope": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-scope/-/dsh-scope-0.2.0-rc.2.tgz", + "integrity": "sha512-066p7Wv74GNWt4xA5Cyvl+7c/i9KrNEDALYh2/hu0VsJPLb4T9He+TY7y1bld9OgungmqyTy1n/cB2ZrVskvxA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-session": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session/-/dsh-session-0.2.0-rc.2.tgz", + "integrity": "sha512-wj+6MeqCYbDcbEvKH3puHyEGdjyu51JwBoN6Ua2tz6griLufDgLM5HpSU40L5xgpbOfrYVhXZs4vRgOrMlFY2g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/dsh-brand": "0.2.0-rc.2", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-util-values": "0.2.0-rc.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-scope": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-session-projection": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-projection/-/dsh-session-projection-0.2.0-rc.2.tgz", + "integrity": "sha512-caJ+XGjmR8ouws0LrvuXrabrzi21akB2l8CniUHdx4IpT6GkLapFYlWLbjZlDYqEWB3ktR+q32nxW2s+f27GSA==", + "license": "MIT", + "peer": true, + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-session": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-settings": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-settings/-/dsh-settings-0.2.0-rc.2.tgz", + "integrity": "sha512-Py4ZbCyady6st+h8wlsPJxrAOg9VrT1+tltymnJUiAmdnZjMADZKWec9JrB2KwUuo27R+IbibxzaYQOvnZdUkg==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/cordis-plugin-loader": "~1.0.5", + "@deepseek-ai/cosmokit": "~1.8.5", + "@deepseek-ai/dsh-config-editor": "0.2.0-rc.2", + "@deepseek-ai/dsh-util-values": "0.2.0-rc.2", + "yaml": "^2.8.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-brand": "0.2.0-rc.2", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2", + "@deepseek-ai/schemastery": "~3.18.4" + } + }, + "node_modules/@deepseek-ai/dsh-system-prompt": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-system-prompt/-/dsh-system-prompt-0.2.0-rc.2.tgz", + "integrity": "sha512-ZZNk2gkXFV8CIT0IOZXS+pFaxNVJaRknYyH2vc4ZgZAiHKT+RqUYNr8e1F5Kp0AaN0DNlcTkjOGP5MCxeXfDjg==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-scope": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-timeout": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-timeout/-/dsh-timeout-0.2.0-rc.2.tgz", + "integrity": "sha512-NDob8b8DJDB/sDPz3P7xhrkxOh1O9FbN1EZRKsxP4fuTfp4hrQU7aekYS5qZENFKoR8dBJMTh/zcEBw4m31glQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-tools": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tools/-/dsh-tools-0.2.0-rc.2.tgz", + "integrity": "sha512-vquUz8PjHWE4zaS3wH70IpYjL441p2UlUcG8TN2yJZN1RhDkuiHum4bWCsjEPyWLS/aB1QJvzdXC2EK/PRkbsQ==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-brand": "0.2.0-rc.2", + "@deepseek-ai/dsh-util-values": "0.2.0-rc.2", + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-agent": "0.2.0-rc.2", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-ptc-runtime": "0.2.0-rc.2", + "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2", + "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.2", + "@deepseek-ai/dsh-scope": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2", + "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2", + "@deepseek-ai/dsh-user-approval": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-typert-protocol": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-typert-protocol/-/dsh-typert-protocol-0.2.0-rc.2.tgz", + "integrity": "sha512-95RvIDcVac2BWdv1IxN6yKlRV+8eEvkofqSxV+d0r8x1kx2EIWEsbdxz6C+coYIm+8e03MiN+rwOt8J7sLfe7Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/dsh-brand": "0.2.0-rc.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-user-approval": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-user-approval/-/dsh-user-approval-0.2.0-rc.2.tgz", + "integrity": "sha512-fZMfDp4IeWZMwAKEQkuAYCiGRrwoVWNObvAbpdmQNUavjfYCtwwduWqYQz+5lMQq8vENdczBj7cxnO/pOQ1oZQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-agent": "0.2.0-rc.2", + "@deepseek-ai/dsh-brand": "0.2.0-rc.2", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-scope": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2", + "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-util-crypto": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-util-crypto/-/dsh-util-crypto-0.2.0-rc.2.tgz", + "integrity": "sha512-5OvvmepY69uXTagUMlnoHqg7euI4ZMifLgfdV8xZO20OslWGGiRCkJbZRyDtcJG9GsXWz9UimfWmUu52zKXJ5w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-util-values": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-util-values/-/dsh-util-values-0.2.0-rc.2.tgz", + "integrity": "sha512-qgpsbgiZABuE9UvgNaVo9b2HiWwLeU93CTl9PFXqE0jN8ap1ALxzQsLGvr/q2MJuWDYqnFBg60XnqnY8ZxPWEg==", + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, + "node_modules/@deepseek-ai/dsh-web": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-web/-/dsh-web-0.2.0-rc.2.tgz", + "integrity": "sha512-d44eM9mMqGnQUGsjqUc6k1G9QrC0w+TWx6Cw6I1iXJkVw5LZ/hpVVzsuCqck8GGS8+GK20kqjGUJSyomfEgd1A==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/schemastery": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/@deepseek-ai/schemastery/-/schemastery-3.18.4.tgz", + "integrity": "sha512-SSXO6tYuyrIqKVbmOnIq0s+riUywYzouFMcnBluHF9n4KMo2G8HvEzhCYj6pj2617/glOjbJuVInJ9hfONsjkg==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/cosmokit": "~1.8.5", + "@standard-schema/spec": "^1.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/dsh-free-search": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/dsh-free-search/-/dsh-free-search-0.8.1.tgz", + "integrity": "sha512-WJ26G+DFrxnrPQXs2IiCH0jApV2SDsAKTAMV5j/KnMqWXME9LPPvCz7wwMAHY7NTkCoZBz8BjIb+3vEAfpZ2JQ==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-client-ui-commands": "^0.1.7-rc.1 || ^0.2.0-rc.1", + "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.1.7-rc.1 || ^0.2.0-rc.1", + "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.1 || ^0.2.0-rc.1", + "@deepseek-ai/dsh-settings": "^0.1.7-rc.1 || ^0.2.0-rc.1", + "@deepseek-ai/dsh-tools": "^0.1.7-rc.1 || ^0.2.0-rc.1", + "@deepseek-ai/schemastery": "^3.18.4", + "react": "^18.2.0" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis": { + "optional": true + }, + "@deepseek-ai/dsh-client-ui-commands": { + "optional": true + }, + "@deepseek-ai/dsh-client-ui-plugin-manager": { + "optional": true + }, + "@deepseek-ai/dsh-client-ui-renderer": { + "optional": true + }, + "@deepseek-ai/dsh-settings": { + "optional": true + }, + "@deepseek-ai/dsh-tools": { + "optional": true + }, + "@deepseek-ai/schemastery": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/node-addon-native-custom-loader": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/node-addon-native-custom-loader/-/node-addon-native-custom-loader-0.1.7.tgz", + "integrity": "sha512-mny2aBXuqWZLu8xFg0Azk6HZd8tlEBbpkOMhideAY3OABE9tp38VeKo8/sw5g/qeYAAdE8oEmsmeJ5IBJd6YHg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin/-/node-addon-require-builtin-0.1.7.tgz", + "integrity": "sha512-2/yiwaMEpIJdrsjwY/09IoLMLKJtyJGlPhtJd80a+B4x0+SYsnAorg5GlDyJHfTb/UgiIYzbNOgL3HHrigFaAQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "node-addon-native-custom-loader": "0.1.7" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "node-addon-require-builtin-darwin-arm64": "0.1.7", + "node-addon-require-builtin-darwin-x64": "0.1.7", + "node-addon-require-builtin-linux-arm64-gnu": "0.1.7", + "node-addon-require-builtin-linux-arm64-musl": "0.1.7", + "node-addon-require-builtin-linux-x64-gnu": "0.1.7", + "node-addon-require-builtin-linux-x64-musl": "0.1.7", + "node-addon-require-builtin-win32-arm64-msvc": "0.1.7", + "node-addon-require-builtin-win32-ia32-msvc": "0.1.7", + "node-addon-require-builtin-win32-x64-msvc": "0.1.7" + } + }, + "node_modules/node-addon-require-builtin-darwin-arm64": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-darwin-arm64/-/node-addon-require-builtin-darwin-arm64-0.1.7.tgz", + "integrity": "sha512-3n5SgmD6S4lCyeFPumbwfUh+YLssWiSF8GuToHdr00hBPhDikyzHCy2UV5qJUTDNNtUpfw9QuFijsC+ulnvmog==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "dependencies": { + "node-addon-native-custom-loader": "0.1.7" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin-darwin-x64": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-darwin-x64/-/node-addon-require-builtin-darwin-x64-0.1.7.tgz", + "integrity": "sha512-ZelNDiBgGH//ZHw6FCv4VdWUxV2YwdZNa0za7nOJIYodGcow0rJX6san45iOI73/gd2qtyKBOrHgylDjnBblbg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "dependencies": { + "node-addon-native-custom-loader": "0.1.7" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin-linux-arm64-gnu": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-linux-arm64-gnu/-/node-addon-require-builtin-linux-arm64-gnu-0.1.7.tgz", + "integrity": "sha512-osAdFdxBUeciYOAIMxxIZzVyPK73zex7xJvSAyfDPyY8cTxhGZVZGQnB0ZtZEy3TRQQ2psjWhu5HbcHXIXotXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "dependencies": { + "node-addon-native-custom-loader": "0.1.7" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin-linux-arm64-musl": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-linux-arm64-musl/-/node-addon-require-builtin-linux-arm64-musl-0.1.7.tgz", + "integrity": "sha512-pN027xk8o64/i1evlncK3VoPGdnflHzMgD+MQ+vApMpu2U6XKc9KAqKiImTpMd+mtGxElkkhD/AXNybF0MscZw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "dependencies": { + "node-addon-native-custom-loader": "0.1.7" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin-linux-x64-gnu": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-linux-x64-gnu/-/node-addon-require-builtin-linux-x64-gnu-0.1.7.tgz", + "integrity": "sha512-HedJA3MGD7I3rH/mPuPIaGTTtSm61L5uHv/90xHPy0tHGlP5cxC9Dt9l/5iwIrTE04LrT0woiG8Ef2Eg2sRyuA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "dependencies": { + "node-addon-native-custom-loader": "0.1.7" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin-linux-x64-musl": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-linux-x64-musl/-/node-addon-require-builtin-linux-x64-musl-0.1.7.tgz", + "integrity": "sha512-4SkkvMTQqwFRkNzWp8Jc+1e/wMzA893YYX6lhRFFXWcb+igpXq/KD92JEWI0RHr9rrQkwF51/9odyt0Jk9WnDA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "dependencies": { + "node-addon-native-custom-loader": "0.1.7" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin-win32-arm64-msvc": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-win32-arm64-msvc/-/node-addon-require-builtin-win32-arm64-msvc-0.1.7.tgz", + "integrity": "sha512-nooBYRrieUMClXU3HBM+9yfOEXC0EJS3kFGJzu7oXocOgADKmAwt3xpF3YunjnyN9bty9/QaoZRlHG5EBX4Usg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "dependencies": { + "node-addon-native-custom-loader": "0.1.7" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-addon-require-builtin-win32-ia32-msvc": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-win32-ia32-msvc/-/node-addon-require-builtin-win32-ia32-msvc-0.1.7.tgz", + "integrity": "sha512-i3/cymlUwzReJTtFLC75m+Gabi1RWa4ym4KgVThGLySy4/EJ0QldTv5VWHg3i7HP/g8gIWXwFrxTFk5v2DGchA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "dependencies": { + "node-addon-native-custom-loader": "0.1.7" + }, + "engines": { + "node": ">=20 <23" + } + }, + "node_modules/node-addon-require-builtin-win32-x64-msvc": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/node-addon-require-builtin-win32-x64-msvc/-/node-addon-require-builtin-win32-x64-msvc-0.1.7.tgz", + "integrity": "sha512-iwYELawC0J+nKFTIfJumU53VzTVDshSFUZCU6oUb4Uw+JAfsXCCA2WaSQbtmHng6ub0HEFqWgfh/Wp+qsEVtNg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "dependencies": { + "node-addon-native-custom-loader": "0.1.7" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/integrations/yoagent-rutis/plugins/dsh/package.json b/integrations/yoagent-rutis/plugins/dsh/package.json new file mode 100644 index 0000000..c9041a6 --- /dev/null +++ b/integrations/yoagent-rutis/plugins/dsh/package.json @@ -0,0 +1,19 @@ +{ + "name": "yoagent-rutis-dsh", + "private": true, + "description": "The dsh tools adapter for yoagent-rutis: DeepSeek Harness tool plugins as yoagent tools, in rutis's Node runtime. Pinned exactly: dsh is a release candidate.", + "type": "module", + "engines": { + "node": ">=24" + }, + "dependencies": { + "@arcships/rutis": "0.7.0", + "@arcships/rutis-runtime": "0.7.0", + "@deepseek-ai/cordis": "4.0.4", + "@deepseek-ai/dsh-settings": "0.2.0-rc.2", + "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2", + "@deepseek-ai/dsh-tools": "0.2.0-rc.2", + "@deepseek-ai/dsh-web": "0.2.0-rc.2", + "dsh-free-search": "0.8.1" + } +} diff --git a/integrations/yoagent-rutis/tests/dsh_test.rs b/integrations/yoagent-rutis/tests/dsh_test.rs new file mode 100644 index 0000000..51b676f --- /dev/null +++ b/integrations/yoagent-rutis/tests/dsh_test.rs @@ -0,0 +1,240 @@ +//! dsh (DeepSeek Harness) tool plugins as yoagent tools, end to end: the +//! adapter `plugins/dsh/dsh-tools-adapter.ts` and a fixture dsh tool plugin +//! (`plugins/dsh/fixture-tools.ts`, no network) loaded with +//! `@deepseek-ai/dsh-system-prompt` and `@deepseek-ai/dsh-tools` as +//! rutis-loader rows of one Node runtime. +//! +//! Needs Node 24+ and `npm ci` in `plugins/dsh/`. Without them the test +//! prints `SKIPPED:` and passes; `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1` (CI) makes +//! it fail instead. +#![cfg(unix)] + +mod common; + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::time::Duration; + +use common::*; +use rutis::Ctx; +use rutis_bridge::runtime::LocalRuntime; +use rutis_loader::{ + Chain, Layer, Loader, LoaderOptions, LoaderPlugin, Patch, RuntimeResolver, RuntimeRowsPlugin, + ServiceCatalog, +}; +use serde_json::{json, Value}; +use yoagent::provider::mock::{MockResponse, MockToolCall}; +use yoagent_rutis::RutisBridge; + +fn dsh_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins/dsh") +} + +/// The Node runtime package, if Node 24+ and `npm ci` in `plugins/dsh/` are there. +fn node_runtime() -> Option { + let skip = |why: String| -> Option { + if std::env::var_os("YOAGENT_RUTIS_REQUIRE_RUNTIMES").is_some_and(|v| v == "1") { + panic!("the dsh runtime is required (YOAGENT_RUTIS_REQUIRE_RUNTIMES=1) but unavailable: {why}"); + } + eprintln!("SKIPPED: the dsh runtime is unavailable: {why}"); + None + }; + let major = Command::new("node") + .arg("--version") + .output() + .ok() + .filter(|out| out.status.success()) + .and_then(|out| { + String::from_utf8_lossy(&out.stdout) + .trim() + .trim_start_matches('v') + .split('.') + .next() + .and_then(|m| m.parse::().ok()) + }); + match major { + Some(major) if major >= 24 => {} + Some(major) => return skip(format!("Node {major} found, 24+ needed")), + None => return skip("no `node` on PATH".into()), + } + let modules = dsh_dir().join("node_modules"); + for package in ["@arcships/rutis-runtime", "@deepseek-ai/dsh-tools"] { + if !modules.join(package).join("package.json").exists() { + return skip(format!( + "{package} missing from {}: run `npm ci` in plugins/dsh/", + modules.display() + )); + } + } + Some(modules.join("@arcships/rutis-runtime")) +} + +struct Host { + root: Ctx, + bridge: RutisBridge, + loader: Loader, +} + +async fn host(runtime: &Path) -> Host { + 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, dsh_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)); + Host { + root, + bridge, + loader, + } +} + +impl Host { + async fn load(&self, rows: Value) { + let patches: Vec = serde_json::from_value(json!([{ "insert": rows }])).unwrap(); + let report = self + .loader + .reconcile(vec![Layer::new("rows", patches)], None) + .await + .unwrap(); + assert!(report.failures.is_empty(), "{report:?}"); + let registry = self.bridge.registry().clone(); + tokio::time::timeout(Duration::from_secs(60), async { + while !registry.handlers().iter().any(|h| h.name() == "dsh-tools") { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("the adapter registers its handler"); + } +} + +fn calls(names: &[(&str, Value)]) -> MockResponse { + MockResponse::ToolCalls( + names + .iter() + .map(|(name, args)| MockToolCall { + provider_metadata: None, + name: (*name).into(), + arguments: args.clone(), + }) + .collect(), + ) +} + +async fn wait_file(path: &Path, want: impl Fn(&str) -> bool) -> String { + tokio::time::timeout(Duration::from_secs(30), async { + loop { + if let Ok(text) = std::fs::read_to_string(path) { + if want(&text) { + return text; + } + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "{} never matched: {:?}", + path.display(), + std::fs::read_to_string(path) + ) + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn dsh_tools_reach_a_yoagent_agent_through_the_adapter() { + let Some(runtime) = node_runtime() else { + return; + }; + let scratch = tempfile::tempdir().unwrap(); + let abort_file = scratch.path().join("slow.txt"); + let host = host(&runtime).await; + host.load(json!([ + { "id": "system-prompt", "name": "@deepseek-ai/dsh-system-prompt" }, + { "id": "tools", "name": "@deepseek-ai/dsh-tools" }, + { + "id": "fixture", + "name": dsh_dir().join("fixture-tools.ts"), + "config": { "abortFile": abort_file }, + }, + { "id": "adapter", "name": dsh_dir().join("dsh-tools-adapter.ts") }, + ])) + .await; + + let (agent, seen) = agent(vec![ + calls(&[ + ("fixture_echo", json!({"text": "hi"})), + ("fixture_fail", json!({"why": "nope"})), + ]), + text("done"), + call("fixture_slow", json!({})), + text("never sent"), + ]); + let mut agent = agent.with_extension(host.bridge.extension()); + let (_, results) = tokio::time::timeout(Duration::from_secs(60), run(&mut agent, "go")) + .await + .expect("the run finishes"); + + // The adapter offered the fixture's tools... + let seen_now = seen.lock().unwrap().clone(); + for tool in ["fixture_echo", "fixture_fail", "fixture_slow"] { + assert!( + seen_now[0].tools.contains(&tool.to_string()), + "{seen_now:?}" + ); + } + // ...a call returns its text... + assert_eq!( + results[0], + ("fixture_echo".into(), "echo: hi".into(), false), + "{results:?}" + ); + // ...an `isError` result is an error tool result... + let (name, text, is_error) = &results[1]; + assert_eq!(name, "fixture_fail"); + assert!( + *is_error && text.contains("fixture failure: nope"), + "{results:?}" + ); + // ...and the plugin's prompt section reached the request as a note, + // without the harness's own sections. + let note = &seen_now[0].last_user; + assert!( + note.contains("[Guidance from dsh plugins]") + && note.contains("Fixture guidance: prefer fixture_echo for echoing."), + "{note}" + ); + assert!(!note.contains("DeepSeek Harness"), "{note}"); + assert!( + !format!("{:?}", agent.messages()).contains("Fixture guidance"), + "the note is never stored" + ); + + // Cancelling the run aborts the dsh tool through its signal. + let mut rx = agent.prompt("slow").await; + wait_file(&abort_file, |t| t == "started").await; + agent.abort(); + tokio::time::timeout(Duration::from_secs(30), async { + while rx.recv().await.is_some() {} + }) + .await + .expect("the cancelled run ends"); + agent.finish().await; + let ended = wait_file(&abort_file, |t| t != "started").await; + assert_eq!(ended, "aborted: AbortError"); + host.root.shutdown().await.unwrap(); +} From 8dd008e1b1585bc229148b399a8b507f8adfa422 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 02:42:54 +0200 Subject: [PATCH 3/7] feat(rutis): rutis-agent tools example workspace examples/rutis-agent-tools maps rutis-agent's ToolRegistry to yoagent tools per run (replace_text, a hot-added tool, a cancelled call). rutis-agent from git at rutis v0.7.0 with a [patch.crates-io] for rutis, so the graph holds one rutis crate. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .../examples/rutis-agent-tools/.gitignore | 1 + .../examples/rutis-agent-tools/Cargo.toml | 35 ++ .../examples/rutis-agent-tools/src/main.rs | 445 ++++++++++++++++++ 3 files changed, 481 insertions(+) create mode 100644 integrations/yoagent-rutis/examples/rutis-agent-tools/.gitignore create mode 100644 integrations/yoagent-rutis/examples/rutis-agent-tools/Cargo.toml create mode 100644 integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs diff --git a/integrations/yoagent-rutis/examples/rutis-agent-tools/.gitignore b/integrations/yoagent-rutis/examples/rutis-agent-tools/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/integrations/yoagent-rutis/examples/rutis-agent-tools/.gitignore @@ -0,0 +1 @@ +/target diff --git a/integrations/yoagent-rutis/examples/rutis-agent-tools/Cargo.toml b/integrations/yoagent-rutis/examples/rutis-agent-tools/Cargo.toml new file mode 100644 index 0000000..0a4961c --- /dev/null +++ b/integrations/yoagent-rutis/examples/rutis-agent-tools/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "rutis-agent-tools" +version = "0.0.0" +edition = "2021" +publish = false +description = "rutis-agent's ToolRegistry tools (replace_text, and tools registered while the host runs) offered to a yoagent agent through yoagent-rutis" + +# PINNING CAVEAT. Services in a rutis root match by Rust type, so rutis-agent +# and yoagent-rutis must share ONE `rutis` crate. crates.io's rutis-agent +# (0.2.0) is built on rutis 0.2, which cannot meet yoagent-rutis' rutis 0.6; +# the rutis-agent built on rutis 0.6 (the rutis repository at tag v0.7.0, +# crates/rutis-agent) is not published. So it comes from git, and the patch +# below swaps crates.io's `rutis` (what yoagent-rutis asks for) for the same +# tag's, leaving one `rutis` in the graph — check with `cargo tree -d`. +# Bump the tag and the patch together, and only to a rutis whose version +# yoagent-rutis' `rutis = "0.6"` still accepts. +[dependencies] +yoagent = { path = "../../../.." } +yoagent-rutis = { path = "../.." } +rutis = "0.6" +rutis-agent = { git = "https://github.com/arcships/rutis", tag = "v0.7.0" } +# For the `Tool::Function` schemas `ToolRegistry::schemas` returns; the +# version rutis-agent v0.7.0 uses. +aimux-core = "0.3" +async-trait = "0.1" +serde_json = "1" +tempfile = "3" +tokio-util = "0.7" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] } + +[patch.crates-io] +rutis = { git = "https://github.com/arcships/rutis", tag = "v0.7.0" } + +# Its own workspace: not part of yoagent's or yoagent-rutis' build. +[workspace] diff --git a/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs b/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs new file mode 100644 index 0000000..acc5f24 --- /dev/null +++ b/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs @@ -0,0 +1,445 @@ +//! rutis-agent's tools as yoagent tools. +//! +//! [rutis-agent](https://github.com/arcships/rutis/tree/v0.7.0/crates/rutis-agent) +//! keeps its tools in a `ToolRegistry` service (`tools_key()`): `ToolDef`s +//! (an aimux `FunctionTool` schema plus an async runner), listed by +//! `schemas()` and run by `execute(call, cancel)`. This example's adapter is +//! a rutis plugin that injects that registry and the bridge's `Registry` and +//! registers one yoagent-rutis `Handler` whose per-run `with_tools` maps every +//! `ToolDef` to a yoagent tool: +//! +//! - its schema (name, description, parameters) is the tool's; +//! - a result is text, a failure (rutis-agent's `ok: false`, its +//! `error: ...` text) is a real `ToolError::Failed`; +//! - yoagent's cancel token *is* the token rutis-agent's `execute` watches, +//! so cancelling the run stops the runner (`ToolError::Cancelled`). +//! +//! The showcase, in a temporary directory: +//! +//! 1. a yoagent agent edits a file with rutis-agent's own `replace_text` +//! (create, a `str_replace` that fails because its text is not there, a +//! good one, view); +//! 2. while the host runs, a `word_count` tool is registered into +//! rutis-agent's `ToolRegistry` — it appears on the agent's next run, with +//! no change to the agent or the adapter (hot add); +//! 3. (scripted only) a slow tool, added the same way, is cancelled with +//! `Agent::abort()`: its runner never finishes. +//! +//! The model is scripted by default; `--live` asks DeepSeek instead +//! (`DEEPSEEK_API_KEY`, else the key in `~/.dskey`). Either way the example +//! checks its outcome and exits non-zero if something is off. +//! +//! **Pinning caveat**: see `Cargo.toml`. rutis-agent comes from git (the +//! rutis repository at tag v0.7.0), with a `[patch.crates-io]` for `rutis`, so +//! that the graph holds one `rutis` crate — rutis matches services by Rust +//! type. +//! +//! Run: `cargo run --manifest-path integrations/yoagent-rutis/examples/rutis-agent-tools/Cargo.toml [-- --live]` + +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use aimux_core::options::Tool; +use rutis::{BoxFuture, CordisError, Ctx, Effect, Plugin, TypeKey}; +use rutis_agent::{replace_text_tool, tool_call, tools_key, ToolDef, ToolRegistry, ToolsPlugin}; +use serde_json::{json, Value}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use yoagent::provider::mock::{MockResponse, MockToolCall}; +use yoagent::provider::{ + MockProvider, ModelConfig, ProviderError, StreamConfig, StreamEvent, StreamProvider, +}; +use yoagent::{ + Agent, AgentEvent, AgentMessage, AgentTool, Content, Message, ToolContext, ToolError, + ToolResult, +}; +use yoagent_rutis::{Handler, PluginCtxExt, Registry, RutisBridge}; + +type BoxError = Box; + +// ── The adapter ───────────────────────────────────────────────── + +/// A rutis plugin offering every tool of rutis-agent's `ToolRegistry` to +/// yoagent agents. It waits for (and reloads with) the registry and the +/// bridge. +struct RutisAgentTools { + injects: Vec, +} + +impl RutisAgentTools { + fn new() -> Self { + Self { + injects: vec![tools_key(), TypeKey::of::()], + } + } +} + +impl Plugin for RutisAgentTools { + fn name(&self) -> &str { + "rutis-agent-tools" + } + + fn injects(&self) -> &[TypeKey] { + &self.injects + } + + fn apply<'a>(&'a self, ctx: &'a Ctx) -> BoxFuture<'a, Result> { + Box::pin(async move { + let registry = ctx + .get::() + .ok_or_else(|| CordisError::ServiceNotFound("rutis_agent::ToolRegistry".into()))?; + // Per run: a tool registered into rutis-agent's registry later is + // offered from the next run on. + ctx.register_handler( + Handler::new("rutis-agent-tools").with_tools(move |_run| tools_of(®istry)), + )?; + Ok(Effect::Done) + }) + } +} + +/// The registry's tools, as yoagent tools, sorted by name (a stable order +/// keeps the request prefix cacheable). +fn tools_of(registry: &Arc) -> Vec> { + let mut tools: Vec<_> = registry + .schemas() + .into_iter() + .filter_map(|schema| match schema { + Tool::Function(function) => Some(function), + _ => None, // provider-defined tools have no runner here + }) + .map(|function| RegistryTool { + description: function.description.clone().unwrap_or_default(), + parameters: function.input_schema.clone(), + name: function.name, + registry: registry.clone(), + }) + .collect(); + tools.sort_by(|a, b| a.name.cmp(&b.name)); + tools + .into_iter() + .map(|tool| Arc::new(tool) as Arc) + .collect() +} + +/// One `ToolDef` of rutis-agent's registry, run through `execute`. +struct RegistryTool { + name: String, + description: String, + parameters: Value, + registry: Arc, +} + +#[async_trait::async_trait] +impl AgentTool for RegistryTool { + fn name(&self) -> &str { + &self.name + } + fn label(&self) -> &str { + &self.name + } + fn description(&self) -> &str { + &self.description + } + fn parameters_schema(&self) -> Value { + self.parameters.clone() + } + async fn execute(&self, params: Value, ctx: ToolContext) -> Result { + let call = tool_call(ctx.tool_call_id.clone(), self.name.clone(), params); + // rutis-agent watches the same token: cancelling the run aborts the + // runner (and kills a `bash` process group). + let out = self.registry.execute(&call, &ctx.cancel).await; + if ctx.cancel.is_cancelled() { + return Err(ToolError::Cancelled); + } + if !out.ok { + // rutis-agent already wrote it for the model: `error: ...`. + return Err(ToolError::Failed(out.output)); + } + Ok(ToolResult { + content: vec![Content::Text { text: out.output }], + details: Value::Null, + }) + } +} + +// ── The host ──────────────────────────────────────────────────── + +/// A tool registered into rutis-agent's registry while the host runs. +fn word_count_tool() -> ToolDef { + ToolDef::new( + "word_count", + "Count the words in a file.", + json!({ + "type": "object", + "properties": { "path": { "type": "string", "description": "Absolute path of the file." } }, + "required": ["path"] + }), + |args: Value| async move { + let path = args["path"].as_str().ok_or("`path` is required")?; + let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?; + Ok(Value::String(format!( + "{} words", + text.split_whitespace().count() + ))) + }, + ) +} + +/// A tool that takes a minute unless cancelled; `finished` says whether its +/// runner ever completed. +fn slow_tool(finished: Arc) -> ToolDef { + ToolDef::new( + "slow", + "Takes a minute.", + json!({"type": "object", "properties": {}}), + move |_| { + let finished = finished.clone(); + async move { + tokio::time::sleep(Duration::from_secs(60)).await; + finished.store(true, Ordering::SeqCst); + Ok(Value::String("slept".into())) + } + }, + ) +} + +/// The scripted model, keeping the tool names of every request. +struct Scripted { + inner: MockProvider, + offered: Arc>>>, +} + +#[async_trait::async_trait] +impl StreamProvider for Scripted { + async fn stream( + &self, + config: StreamConfig, + tx: mpsc::UnboundedSender, + cancel: CancellationToken, + ) -> Result { + self.offered + .lock() + .unwrap() + .push(config.tools.iter().map(|t| t.name.clone()).collect()); + self.inner.stream(config, tx, cancel).await + } +} + +fn call(name: &str, args: Value) -> MockResponse { + MockResponse::ToolCalls(vec![MockToolCall { + provider_metadata: None, + name: name.into(), + arguments: args, + }]) +} + +fn text_of(content: &[Content]) -> String { + content + .iter() + .filter_map(|c| match c { + Content::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n") +} + +/// The tool results among `messages`: (tool, text, is_error). +fn results(messages: &[AgentMessage]) -> Vec<(String, String, bool)> { + messages + .iter() + .filter_map(|m| match m { + AgentMessage::Llm(Message::ToolResult { + tool_name, + content, + is_error, + .. + }) => Some((tool_name.clone(), text_of(content), *is_error)), + _ => None, + }) + .collect() +} + +/// Run one prompt; print and return this run's tool results. +async fn run(agent: &mut Agent, prompt: &str) -> Result, BoxError> { + println!("\n> {prompt}"); + let before = agent.messages().len(); + let (tx, _rx) = mpsc::unbounded_channel(); + tokio::time::timeout( + Duration::from_secs(180), + agent.prompt_with_sender(prompt, tx), + ) + .await + .map_err(|_| "the run did not finish within 180 s")?; + let results = results(&agent.messages()[before..]); + for (tool, text, is_error) in &results { + let mark = if *is_error { "error" } else { "ok" }; + let shown: String = text.chars().take(300).collect(); + println!(" [{tool}: {mark}] {shown}"); + } + Ok(results) +} + +fn deepseek_key() -> Result { + if let Ok(key) = std::env::var("DEEPSEEK_API_KEY") { + if !key.trim().is_empty() { + return Ok(key.trim().to_string()); + } + } + let path = Path::new(&std::env::var("HOME")?).join(".dskey"); + let key = std::fs::read_to_string(&path) + .map_err(|e| format!("--live needs DEEPSEEK_API_KEY or {}: {e}", path.display()))?; + Ok(key.split_whitespace().collect()) +} + +fn check(ok: bool, what: impl Into) -> Result<(), BoxError> { + if ok { + Ok(()) + } else { + Err(what.into().into()) + } +} + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + let live = std::env::args().any(|a| a == "--live"); + let dir = tempfile::tempdir()?; + let notes = dir.path().join("notes.txt"); + let path = notes.to_string_lossy().to_string(); + + // rutis: the bridge, rutis-agent's tool registry with its real + // `replace_text`, and the adapter. + let root = Ctx::root()?; + let bridge = RutisBridge::install(&root)?; + (&root.plugin(ToolsPlugin::new(vec![replace_text_tool()]))).await?; + (&root.plugin(RutisAgentTools::new())).await?; + let registry = root + .get::() + .ok_or("rutis-agent's ToolRegistry is not provided")?; + + let offered = Arc::new(Mutex::new(Vec::>::new())); + let mut agent = if live { + Agent::from_config(ModelConfig::deepseek("deepseek-flash", "DeepSeek Flash")) + .with_api_key(deepseek_key()?) + } else { + let script = MockProvider::new(vec![ + // Run 1: replace_text. + call( + "replace_text", + json!({"command": "create", "path": path, "file_text": "rutis loads plugins\nyoagent runs agents\n"}), + ), + call( + "replace_text", + json!({"command": "str_replace", "path": path, "old_str": "not in the file", "new_str": "x"}), + ), + call( + "replace_text", + json!({"command": "str_replace", "path": path, "old_str": "yoagent runs agents", "new_str": "yoagent runs agents with rutis tools"}), + ), + call("replace_text", json!({"command": "view", "path": path})), + MockResponse::Text("Edited.".into()), + // Run 2: the tool added while the host ran. + call("word_count", json!({"path": path})), + MockResponse::Text("Counted.".into()), + // Run 3: cancelled mid-call. + call("slow", json!({})), + MockResponse::Text("never sent".into()), + ]); + Agent::from_provider( + Scripted { + inner: script, + offered: offered.clone(), + }, + ModelConfig::mock(), + ) + } + .with_system_prompt("You edit files with the tools you have. Use absolute paths. Be brief.") + .with_extension(bridge.extension()); + + // 1. rutis-agent's replace_text, driven by a yoagent agent. + let first = run( + &mut agent, + &format!( + "Create {path} with the two lines `rutis loads plugins` and `yoagent runs agents`, \ + then change the second line to `yoagent runs agents with rutis tools`, \ + then view the file." + ), + ) + .await?; + let content = std::fs::read_to_string(¬es).unwrap_or_default(); + check( + content == "rutis loads plugins\nyoagent runs agents with rutis tools\n", + format!("replace_text left {path} as {content:?}"), + )?; + check( + first.iter().any(|(t, _, e)| t == "replace_text" && !e), + "no replace_text call succeeded", + )?; + if !live { + // A failure is a real tool error, carrying rutis-agent's text. + check( + first + .iter() + .any(|(_, text, e)| *e && text.contains("error:")), + format!("the failing str_replace was not an error: {first:?}"), + )?; + } + + // 2. Hot add: register a tool into rutis-agent's registry while the host + // runs. Nothing else changes; the next run offers it. + registry.register(word_count_tool()); + let second = run( + &mut agent, + &format!("How many words are in {path}? Use word_count."), + ) + .await?; + check( + second + .iter() + .any(|(t, text, e)| t == "word_count" && !e && text == "9 words"), + format!("word_count did not answer `9 words`: {second:?}"), + )?; + + if !live { + let offered = offered.lock().unwrap().clone(); + check( + !offered[0].contains(&"word_count".to_string()) + && offered.last().unwrap().contains(&"word_count".to_string()), + format!("word_count was not hot-added between the runs: {offered:?}"), + )?; + + // 3. Cancel: yoagent's token stops rutis-agent's runner. + let finished = Arc::new(AtomicBool::new(false)); + registry.register(slow_tool(finished.clone())); + let mut events = agent.prompt("Take your time.").await; + while let Some(event) = events.recv().await { + if matches!(&event, AgentEvent::ToolExecutionStart { tool_name, .. } if tool_name == "slow") + { + tokio::time::sleep(Duration::from_millis(100)).await; + agent.abort(); + } + } + agent.finish().await; + let third = results(agent.messages()); + let slow = third.iter().rev().find(|(t, ..)| t == "slow"); + println!("\n> (cancelled) {slow:?}"); + check( + matches!(slow, Some((_, _, true))), + format!("the cancelled call was no error: {slow:?}"), + )?; + tokio::time::sleep(Duration::from_millis(200)).await; + check( + !finished.load(Ordering::SeqCst), + "the slow runner kept going", + )?; + } + + tokio::time::timeout(Duration::from_secs(10), root.shutdown()) + .await + .map_err(|_| "shutdown did not finish within 10 s")??; + println!("\nok"); + Ok(()) +} From d4cb79aa999c580bc27eadb96251b354d94c6369 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 02:42:54 +0200 Subject: [PATCH 4/7] docs(rutis): cancel handles, dsh adapter, rutis-agent example Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 5 +- CLAUDE.md | 2 +- integrations/yoagent-rutis/README.md | 90 ++++++++++++++++++++++++---- 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5940b45..65941fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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; diff --git a/CLAUDE.md b/CLAUDE.md index 4a3695a..e3da8fb 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`). `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. No AbortSignal is passed (rutis-bridge 0.7 sends one only as an extra positional argument, which fixed-signature Python methods refuse, and a JS object cannot be told from a Python instance): an abandoned JS hook — `call_tool` after a run cancel included — runs to completion; Python coroutines are cancelled (documented in the README and `yoagent.d.ts`). `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: clippy per feature, docs, tests with the runtimes required, the example). 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`). `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`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 938624e..c892e94 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -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 @@ -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 From 49d862eb80cca4edba723d4f5d3d4ade647e7856 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 02:48:44 +0200 Subject: [PATCH 5/7] fix(rutis): rutis-agent example keeps a finished result when the cancel came after Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .../yoagent-rutis/examples/rutis-agent-tools/src/main.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs b/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs index acc5f24..78c6e76 100644 --- a/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs +++ b/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs @@ -151,10 +151,11 @@ impl AgentTool for RegistryTool { // rutis-agent watches the same token: cancelling the run aborts the // runner (and kills a `bash` process group). let out = self.registry.execute(&call, &ctx.cancel).await; - if ctx.cancel.is_cancelled() { - return Err(ToolError::Cancelled); - } if !out.ok { + // A finished result stands even if the cancel came right after. + if ctx.cancel.is_cancelled() { + return Err(ToolError::Cancelled); + } // rutis-agent already wrote it for the model: `error: ...`. return Err(ToolError::Failed(out.output)); } From 18ceb59354748910685a5ad0221c8eef7f2ed65f Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 02:55:21 +0200 Subject: [PATCH 6/7] fix(yoagent-rutis): cut the dsh guidance note by code points A UTF-16 slice could leave a lone surrogate, which the host's JSON decoding rejects, closing the whole Node runtime session (#261 review). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts b/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts index 8da7356..efe4cfd 100644 --- a/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts +++ b/integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts @@ -137,7 +137,10 @@ export default definePlugin({ } if (parts.length === 0) return let note = `[Guidance from dsh plugins]\n${parts.join('\n\n')}` - if (note.length > maxNote) note = `${note.slice(0, Math.max(0, maxNote - 1))}…` + // Cut by code points, not UTF-16 units: a lone surrogate would fail the + // host's JSON decoding and close the whole runtime session. + const chars = [...note] + if (chars.length > maxNote) note = `${chars.slice(0, Math.max(0, maxNote - 1)).join('')}…` return { note } }, }), From bab42bed690eb2283f7f4fb4bf08bc8010a022b8 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 03:00:23 +0200 Subject: [PATCH 7/7] docs(rutis-agent example): say what a cancelled call's result actually shows Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .../yoagent-rutis/examples/rutis-agent-tools/src/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs b/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs index 78c6e76..ce1272d 100644 --- a/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs +++ b/integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs @@ -12,7 +12,10 @@ //! - a result is text, a failure (rutis-agent's `ok: false`, its //! `error: ...` text) is a real `ToolError::Failed`; //! - yoagent's cancel token *is* the token rutis-agent's `execute` watches, -//! so cancelling the run stops the runner (`ToolError::Cancelled`). +//! so cancelling the run stops the runner. The tool returns +//! `ToolError::Cancelled`, but the transcript shows yoagent's "Tool result +//! withheld…" error instead: the cancel also cuts off the bridge's `after_tool`, +//! and yoagent withholds a result an extension didn't finish with. //! //! The showcase, in a temporary directory: //!