diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc91842..b01086b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -290,6 +290,13 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 + # npm's download cache (~/.npm), keyed on the three lockfiles: the + # installs below (pi's alone is ~185 MB) stop re-downloading. + cache: npm + cache-dependency-path: | + integrations/yoagent-rutis/plugins/package-lock.json + integrations/yoagent-rutis/plugins/dsh/package-lock.json + integrations/yoagent-rutis/plugins/pi/package-lock.json - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -299,6 +306,10 @@ jobs: # 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" + # The pi adapter's end-to-end test (tests/pi_test.rs): pi's own + # packages (its extension loader, TypeBox), in their own install. + - name: Install the pi npm packages + run: npm ci --prefix "$PLUGINS/pi" - name: Install the rutis Python package run: | python -m venv "$PLUGINS/.venv" @@ -314,6 +325,8 @@ jobs: run: cargo test --manifest-path "$MANIFEST" --features node,python,websocket - name: Example run: cargo run --manifest-path "$MANIFEST" --features node,python --example language_plugins + - name: pi extensions example (scripted, the fixture extension) + run: cargo run --manifest-path "$MANIFEST" --features node --example pi_extensions # yoagent claims to be a tested GASP-conformant runtime — this job is the # test: emit an agent repo with the gasp bridge (mock provider, no network) diff --git a/.github/workflows/pi-latest.yml b/.github/workflows/pi-latest.yml new file mode 100644 index 0000000..3763dc3 --- /dev/null +++ b/.github/workflows/pi-latest.yml @@ -0,0 +1,54 @@ +name: pi latest + +# The pi extensions adapter (integrations/yoagent-rutis/plugins/pi) pins pi +# exactly (1.1.0) and imports pi's extension loader by its internal file path +# (dist/core/extensions/loader.js), which pi's export map does not promise. +# This job runs the adapter's end-to-end tests against the LATEST pi packages +# weekly, so a pi release that breaks the adapter shows up here before anyone +# bumps the pin. A red run means: check the adapter against that pi release. +# It never changes the pin or the lockfile. +on: + schedule: + # Tuesdays 04:23 UTC. + - cron: "23 4 * * 2" + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + MANIFEST: integrations/yoagent-rutis/Cargo.toml + PI: integrations/yoagent-rutis/plugins/pi + +permissions: + contents: read + +jobs: + pi-latest: + name: pi adapter vs latest pi + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: integrations/yoagent-rutis + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Install the pinned packages, then the latest pi on top + run: | + npm ci --prefix "$PI" + npm install --prefix "$PI" --no-save \ + @earendil-works/pi-coding-agent@latest \ + @earendil-works/pi-ai@latest \ + @earendil-works/pi-tui@latest \ + typebox@latest + # Versions read from the installed packages: `npm ls` exits 1 on a + # version that differs from package.json's exact pin, which is the point here. + for p in @earendil-works/pi-coding-agent @earendil-works/pi-ai @earendil-works/pi-tui typebox; do + echo "$p $(node -p "require('./$PI/node_modules/$p/package.json').version")" + done + - name: The adapter's tests against it + env: + YOAGENT_RUTIS_REQUIRE_RUNTIMES: "1" + run: cargo test --manifest-path "$MANIFEST" --features node --test pi_test diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a244ad..14bdb81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ adheres to [Semantic Versioning](https://semver.org/). ### yoagent-rutis +- **pi extensions** (`plugins/pi/pi-extensions-adapter.ts`): the tools and tool policies of [pi](https://github.com/earendil-works/pi) coding-agent extensions, loaded unchanged with pi's own loader, as one yoagent handler following pi 1.1.0's semantics. `registerTool` → tools (pi's activation and first-registration-wins, `prepareArguments` and validation before the policies, only the judged arguments run), `setActiveTools` → an enforced allowlist, `tool_call` → `before_tool` (yoagent's built-ins under pi's names, arguments translated both ways, relative paths resolved where the policies look; an override of a built-in is enforced; `terminate` stops the run), `tool_result` → `after_tool` (a failing handler withholds the result), `input` → `on_input`, `before_agent_start` additions → a turn note. Fails closed: a load error, a failing `session_start`, an unfired deciding event (`context`, `message_end`, ...; unless `allowUnmapped`) or a pi tool named like a yoagent built-in (unless `withoutBuiltins`) refuses the load; other unmapped API (observer and session events, commands, renderers, providers, MCP servers) is reported, or refuses with `strict`. Example `pi_extensions` (any extension files; `--live` with DeepSeek; `--without`), test `pi_test`. `ctx.executeTool` runs pi tools as nested calls (pi's pipeline; never rejects); a deciding event registered mid-run stops that run; adapter warnings reach the host's logs. `pi.appendEntry` / `pi.sendMessage` are recorded in the adapter's in-memory session instead of throwing (pi tools that record their result this way no longer fail after the work). CI: a weekly `pi latest` job runs the adapter's tests against the newest pi. - **Images in tool results, across ecosystems.** TypeScript and Python handlers' `call_tool` results and `after_tool` edits take `content` blocks in yoagent's JSON shape (`{"type": "image", "data": , "mimeType"}` next to text blocks) instead of `text`, so pictures cross the bridge both ways (`after_tool` already saw `output.content`). The dsh adapter turns dsh image blocks (references into dsh's attachment store) into yoagent images through the `attachments` service when one is loaded; the rutis-agent example reads a runner's `{"content": [...]}` value as blocks (rutis-agent results are otherwise text). Rust handlers already returned full yoagent tool results. Tests: `content_blocks` unit test, TypeScript/Python round trip in `languages_test`, dsh with and without a store in `dsh_test`. - **Plugin logs reach the host.** The `yoagent` service gains `log(level, message, { run_id }?)`: a TypeScript/Python plugin's diagnostics go to the host's `tracing` output (target `yoagent_rutis::plugin`, with the run as a `run_id` field) instead of the runtime process's stderr. It never rejects (a rejected, uncaught call would end the plugin's runtime): any message, extra context fields ignored. In Python it is a coroutine to `await`. The dsh adapter uses it. Test: `tests/plugin_log_test.rs`. - **Docs: one contract, other ecosystems.** The extensions guide shows DSH and rutis-agent plugging in through `Extension` and the bridge with no core change (pi in review, #265); the bridge README states the adapter contract (map what the host honours, refuse what would decide unenforced, deny on failing policies, withhold on failing redactions, report what is ignored). CI now also runs the rutis-agent example. diff --git a/CLAUDE.md b/CLAUDE.md index 812048c..f2cec45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Cloudflare Workers bindings (objects the runtime hands a Worker in `env`) as yoa ### rutis bridge (`integrations/yoagent-rutis/`, separate crate) -Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.6"` (0.x caret = 0.6.x; any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Python plugins** (`languages.rs`, features `node`/`python`/`websocket` → optional `rutis-bridge = "0.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (websocket frame limit 16 MiB), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7 allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.7.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY` / `~/.dskey`; self-checks). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.7.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.7.0), `requirements.txt` (`rutis==0.7.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 24 / `plugins/node_modules` / Python with rutis 0.7 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.86, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 24, Python 3.12: `npm ci` in `plugins/` and `plugins/dsh/`, clippy per feature, docs, tests with the runtimes required, the `language_plugins` example; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. +Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.6"` (0.x caret = 0.6.x; any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Python plugins** (`languages.rs`, features `node`/`python`/`websocket` → optional `rutis-bridge = "0.7"`, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.7 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (websocket frame limit 16 MiB), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7 allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.7.0, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo / fail / slow-until-abort writing `config.abortFile`, and a `fixture:guidance` section); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY` / `~/.dskey`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.7.0; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context`, `context_with_system`, `message_end`, `before_provider_request`, `before_provider_headers`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `console.warn` each (`reported` set; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.7.0 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.7.0), `requirements.txt` (`rutis==0.7.0`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 24 / `plugins/node_modules` / Python with rutis 0.7 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.86, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 24, Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. ### OpenAPI Integration (`openapi/`, feature-gated) diff --git a/docs/concepts/extensions.md b/docs/concepts/extensions.md index 1be9329..736801a 100644 --- a/docs/concepts/extensions.md +++ b/docs/concepts/extensions.md @@ -165,13 +165,13 @@ So is the [`yoagent-rutis`](https://github.com/yologdev/yoagent/tree/main/integr ## One contract, other ecosystems -`Extension` is the whole contract a plugin system needs: tools, policies, notes, verifiers and events go through the same hooks whichever ecosystem a plugin comes from. Through the rutis bridge, two foreign ecosystems plug into yoagent today and a third is in review — all with **no change to yoagent's core**: +`Extension` is the whole contract a plugin system needs: tools, policies, notes, verifiers and events go through the same hooks whichever ecosystem a plugin comes from. Through the rutis bridge, three foreign ecosystems plug into yoagent — all with **no change to yoagent's core**: | Ecosystem | What its plugins are | How they reach yoagent | What crosses | |---|---|---|---| | [DSH](https://github.com/yologdev/yoagent/tree/main/integrations/yoagent-rutis#dsh-deepseek-harness-tool-plugins) (DeepSeek Harness) | Cordis plugins in Node | a small adapter over DSH's own tool registry (`plugins/dsh/`) | tools (images included), the system-prompt sections plugins add (as a turn note), cancellation | | [rutis-agent](https://github.com/yologdev/yoagent/tree/main/integrations/yoagent-rutis#rutis-agent-tools) | Rust tools in a rutis registry | a Rust rutis plugin (`examples/rutis-agent-tools/`) | tools (images by convention), hot-added tools, cancellation | -| [pi](https://github.com/earendil-works/pi) | TypeScript extensions written against pi's `ExtensionAPI` | an adapter that loads them with pi's own loader (experimental, [yologdev/yoagent#265](https://github.com/yologdev/yoagent/pull/265)) | tools, tool policies, input checks, prompt additions, images | +| [pi](https://github.com/earendil-works/pi) | TypeScript extensions written against pi's `ExtensionAPI` | an adapter that loads them with pi's own loader ([`plugins/pi/`](https://github.com/yologdev/yoagent/tree/main/integrations/yoagent-rutis#pi-extensions-tools-and-tool-policies), experimental) | tools, tool policies, input checks, prompt additions, images | What the bridge gives every ecosystem alike: TypeScript and Python handlers next to Rust ones, one registration order, **image** tool results both ways (`content` blocks), **plugin logs** in the host's `tracing` output (`yoagent.log`, with the run they belong to), and failures that deny rather than allow. What stays outside the loop on purpose — commands, dialogs, session history, a UI — belongs to the app that hosts the agent. diff --git a/integrations/yoagent-rutis/Cargo.toml b/integrations/yoagent-rutis/Cargo.toml index 059782f..acc69ef 100644 --- a/integrations/yoagent-rutis/Cargo.toml +++ b/integrations/yoagent-rutis/Cargo.toml @@ -17,6 +17,7 @@ exclude = [ "plugins/node_modules", "plugins/.venv", "plugins/dsh/node_modules", + "plugins/pi/node_modules", "examples/rutis-agent-tools", ] # yoagent's MSRV; rutis 0.6.1 declares 1.85. @@ -76,10 +77,18 @@ required-features = ["node", "python"] name = "dsh_test" required-features = ["node"] +[[test]] +name = "pi_test" +required-features = ["node"] + [[test]] name = "plugin_log_test" required-features = ["node"] +[[test]] +name = "pi_log_test" +required-features = ["node"] + [[example]] name = "language_plugins" required-features = ["node", "python"] @@ -87,3 +96,7 @@ required-features = ["node", "python"] [[example]] name = "dsh_tools" required-features = ["node"] + +[[example]] +name = "pi_extensions" +required-features = ["node"] diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 9aa02fa..6129166 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -349,13 +349,120 @@ cargo run --manifest-path examples/rutis-agent-tools/Cargo.toml [-- --live] > tag (`[patch.crates-io]`), leaving one `rutis` in the graph (`cargo tree -d` > shows none twice). Move the tag and the patch together. +### pi extensions (tools and tool policies) + +> **Experimental.** The pi and DSH adapters live here for now, and are +> expected to move to the yo app (or their own packages) once it hosts +> plugins: completing them needs app services — commands, dialogs, +> sessions — that a loop library does not have. The bridge itself stays. + +[pi](https://github.com/earendil-works/pi) extensions are TypeScript +modules written against pi's `ExtensionAPI`. +[`plugins/pi/pi-extensions-adapter.ts`](plugins/pi/pi-extensions-adapter.ts) +loads them unchanged with pi's own loader (pi's packages are installed in +`plugins/pi/`, so their imports — TypeBox, `defineTool`, pi's helpers — are +the real ones) and maps the part of the API that belongs to an agent loop +onto one handler, following pi 1.1.0's own runner and agent loop: + +| pi | yoagent | +|---|---| +| `pi.registerTool` | a tool, while pi would activate it (exposure `direct` / `model-only`, `defaultActive` not false, in the `setActiveTools` allowlist if one was set; the first registration of a name wins). Arguments go through `prepareArguments` and pi's validation before any policy sees them, and `execute` runs only with the arguments the policies left — a call another handler rewrote afterwards is not run. A throw or `isError` is an error result; text and image blocks pass through (pi's shape is yoagent's) | +| `ctx.executeTool` | another pi tool, the way pi runs a nested call: prepared, validated, judged by `tool_call` (with `parentToolCallId`), its result through `tool_result`; never rejects (an unknown tool, a block or a throw is `isError`). yoagent's own tools are not reachable from it; `ctx.tools` lists the callable ones | +| `pi.setActiveTools` | an allowlist over the tools pi knows — yoagent's built-ins under pi's names and the extensions' tools: those outside it are not offered and their calls are denied. Tools pi does not know (MCP tools, sub-agents, the host's own) are not affected, as in pi. As in pi, a tool named in it is activated even when `defaultActive` is false (not when hidden), and a tool registered later that pi would activate joins it. `getActiveTools` / `getAllTools` answer from the same view | +| `on("tool_call")` | `before_tool` for every call. yoagent's built-ins are judged under pi's names (`bash`, `read_file`→`read`, `write_file`→`write`, `edit_file`→`edit` with `edits`, `search`→`grep` with `glob` / `ignoreCase` (yoagent's search is case-insensitive unless asked, pi's grep the reverse), `list_files`→`find`; config `toolNames` adds more), with a relative `path` resolved against `cwd` first so the tool acts where the policy looked (yoagent's built-ins only: another tool's `path` may be a repository's). `{ block }` denies (`terminate: true` also stops the run at its next model request — stricter than pi, which ends only a batch whose results all set it); in-place changes to `event.input` rewrite the arguments, and one the yoagent tool cannot take (a second edit, a `timeout` on `bash`) denies the call; a throwing handler blocks, as in pi | +| `on("tool_result")` | `after_tool`: content, details and isError edits, chained; only the fields a handler set are applied (a replaced content keeps its text and image blocks). A throwing handler **withholds the result** — unlike pi, which skips it: a failed redaction must not let the raw output through | +| `on("input")` | `on_input`: `handled` rejects the prompt (it never reaches the agent, as in pi); `transform` and a throwing handler reject it too (yoagent cannot rewrite a prompt) | +| `on("before_agent_start")` | `before_model`: the handlers run once per run, and the text they add around `event.systemPrompt` is a note on the latest user turn of every request of that run. A handler that throws, replaces the prompt or changes `systemPromptOptions` is skipped with a warning — even under `strict` (a run cannot refuse the load), so a policy that replaces the prompt (a "read-only mode") does not apply; the others still count. A returned `message` is dropped, the same handler's addition kept | +| `on("session_start")` / `on("session_shutdown")` | when the adapter loads / unloads; a `session_start` handler that throws refuses the load (its extension may be missing its policies) | + +**What refuses the load** (fail closed, `strict` or not): an extension that +fails to load (nothing loads, not just the bad one); a `session_start` +failure; a handler for an event yoagent never fires that would decide or +rewrite what the agent does (`context`, `context_with_system`, +`message_end`, `before_provider_request`, `before_provider_headers`) unless +the host lists it in `allowUnmapped`; and a pi tool named exactly like a +yoagent built-in (pi's sandboxed `bash`) unless the host left that built-in +out and says so in `withoutBuiltins` — otherwise yoagent's own tool would +win the merge and run unsandboxed. Anything of this kind registered after +load (inside a handler or a tool) is caught at the next model request or +tool call and stops the adapter instead: that run stopped, every later call +denied, prompt rejected. + +**Overrides.** An extension tool that replaces one of pi's built-ins under +another name than yoagent's (`read`, `write`, `edit`, `grep`, `find`) makes +the adapter deny yoagent's counterpart (`read_file`, ...), so the model +cannot go around it. + +**What is only reported** (a warning in the host's `tracing` logs, through the bridge's `log`; config +`strict: true` makes it a load failure): every other unfired event — +observers such as `agent_end` or `tool_execution_*`, pi's session events, +the boundary events `turn_end` / `agent_before_settle` — commands, +shortcuts, flags, renderers, model providers, virtual models and MCP +servers. `pi.appendEntry` and `pi.sendMessage` are recorded in the +adapter's in-memory session — readable through `ctx.sessionManager`, a +displayed message also logged — so a tool that records or shows its result +this way (pi video tools do, after the paid work) does not fail; a message +never starts a turn or reaches the model (yoagent's history is written only +by its host). The session lives as long as the adapter: in a long-running +host it grows until the plugin reloads. Other runtime actions (`pi.sendUserMessage`, `pi.setModel`, +...) throw "not available in yoagent". There is no UI: `ctx.hasUI` is false and +`ctx.ui` behaves as in pi's print mode (`confirm` answers false), so a +policy that would ask the user denies. Commands, dialogs and session +history are planned as host-level plugin services — `ui` and `commands` +provided by the attached client, `session` by the host that owns the +session — which the adapter would route `ctx.ui.*`, `registerCommand` and +the session calls to; without them it stays in print mode (design: yo's +`docs/WEB-UI-DESIGN.md` §7, [yoyo-meme/yo#3](https://github.com/yoyo-meme/yo/pull/3); not built). + +**Host setup.** Install the bridge's extension with `.require_policy()`, so +a run that starts before the adapter registered (or after it failed to +load) has every tool call denied rather than unjudged; and with +`.rechecks_modified_calls()` when other handlers can rewrite calls after +the adapter (pi tools are protected by the adapter itself; yoagent's +built-ins are not). Policies cover what the agent calls under the names pi +knows: a pi policy for `write` does not see a `bash` command that writes +the same file (as in pi), nor tools outside `TOOL_NAMES` / `toolNames` +(MCP tools, sub-agents, your own) under any but their own names. + +Config: `extensions` (files, or directories with an `index.ts` / +`index.js`, loaded in order; relative paths resolve against `cwd`), `cwd` +(the project the extensions see; default the runtime's), `name`, +`toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`. Load it as a +row of a Node runtime whose `package.json` is `plugins/pi/`'s (pi 1.1.0, +pinned exactly: the adapter imports pi's loader by file, since the package +exports only `discoverAndLoadExtensions`, which also loads +`/.pi/extensions` and `~/.pi/agent/extensions`; check it on every pi +upgrade), with `yoagent` shared in the loader's catalog. + +```sh +(cd plugins/pi && npm ci) +cargo run --features node --example pi_extensions # the fixture extension, scripted +cargo run --features node --example pi_extensions -- hello.ts todo.ts ... # your pi extensions +cargo run --features node --example pi_extensions -- --live --prompt "..." [--without bash] EXT.ts ... # DeepSeek +``` + +The example runs yoagent's own tools in the temporary project the +extensions see (it is the process's working directory). + +Tried (pi 1.1.0, October 2026) with eleven of pi's own examples, unchanged, +scripted and live with DeepSeek: `hello`, `todo`, `tool-override` (`read`; +yoagent's `read_file` is then denied), `truncated-tool` (`rg`) and +`dynamic-tools` offer working tools; `protected-paths` and +`permission-gate` judge yoagent's own `write_file` and `bash`; +`claude-rules` loads (its note needs a project with `.claude/rules/`, which +these runs did not have). `pirate` only acts after its `/pirate` command, +and `dirty-repo-guard` and `confirm-destructive` only on pi's session +events, so under the adapter they load and do nothing (reported). +`tests/pi_test.rs` covers the adapter offline with the fixture extensions +(`plugins/pi/fixture-extension.ts`, `plugins/pi/fixture-extra.ts`) and small +ones written per test. ## Writing an ecosystem adapter An adapter makes another plugin system's plugins (DSH's, rutis-agent's, pi's) into handlers. Their APIs rarely map one-to-one — an ecosystem has commands, dialogs, sessions, model routing — so this is the contract an -adapter should follow, learned from the DSH and rutis-agent adapters above -and the pi adapter in review ([#265](https://github.com/yologdev/yoagent/pull/265)). +adapter should follow, learned from the DSH, rutis-agent and pi adapters +above. The point is that **a plugin's safety policy never silently stops applying**. Not every rule arises for every adapter: the DSH adapter maps only tools and prompt sections, so it has nothing to refuse (2) or record diff --git a/integrations/yoagent-rutis/examples/pi_extensions.rs b/integrations/yoagent-rutis/examples/pi_extensions.rs new file mode 100644 index 0000000..f5d3e57 --- /dev/null +++ b/integrations/yoagent-rutis/examples/pi_extensions.rs @@ -0,0 +1,324 @@ +//! pi coding-agent extensions, unchanged, in a yoagent agent: the adapter +//! `plugins/pi/pi-extensions-adapter.ts` loads them with pi's own loader in a +//! rutis Node runtime and registers their tools and tool policies with the +//! bridge. The agent also has yoagent's built-in tools, which the policies +//! judge under pi's names (`write_file` as `write`, `bash` as `bash`, ...). +//! +//! Pass extension files as arguments (default: the adapter's test fixture), +//! e.g. examples from pi's repository (`packages/coding-agent/examples/extensions/`). +//! The project they see (`ctx.cwd`) is a fresh temporary directory. +//! +//! By default the model is scripted (MockProvider): it asks for a write to +//! `.env` and a `sudo` command (only echoed) through yoagent's own tools, so +//! the policies decide, +//! and the example prints the tools offered, the prompt note and each +//! outcome. `--live` gives the agent to DeepSeek instead (`DEEPSEEK_API_KEY`, +//! else the key in `~/.dskey`) with `--prompt ""` (a default asks it to +//! use whatever tools it has). +//! +//! The project is also the process's working directory, so yoagent's own +//! tools (`bash`, relative paths) act there, not where `cargo run` started. +//! `--without NAME` leaves a yoagent built-in out (repeatable) and tells the +//! adapter so (`withoutBuiltins`): needed when an extension replaces one +//! under the same name, e.g. pi's sandboxed `bash` — the adapter refuses to +//! load otherwise, since yoagent's own tool would win and run unsandboxed. +//! +//! Setup (once): `npm ci` in `plugins/pi/` (Node 24+). +//! +//! Run: `cargo run --manifest-path integrations/yoagent-rutis/Cargo.toml --features node --example pi_extensions [-- [--live] [--prompt TEXT] [--without NAME] EXT.ts ...]` + +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::tools::default_tools; +use yoagent::{Agent, AgentEvent, AgentMessage, Content, Message}; +use yoagent_rutis::RutisBridge; + +type BoxError = Box; +/// Each request's tool names and latest user turn. +type SeenRequests = Arc, String)>>>; + +fn pi_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins/pi") +} + +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 each request's tools and latest user turn +/// (where the adapter's note lands). +struct Scripted { + inner: MockProvider, + seen: SeenRequests, +} + +#[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, + }); + let tools = config.tools.iter().map(|t| t.name.clone()).collect(); + self.seen + .lock() + .unwrap() + .push((tools, 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 mut live = false; + let mut without = Vec::new(); + let mut prompt = "Use the tools you have to do one small useful thing in this project, then say what you did. Be brief.".to_string(); + let mut extensions = Vec::new(); + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--live" => live = true, + "--prompt" => prompt = args.next().ok_or("--prompt needs a value")?, + "--without" => without.push(args.next().ok_or("--without needs a tool name")?), + path => extensions.push(std::fs::canonicalize(path)?), + } + } + let fixture = extensions.is_empty(); + if fixture { + extensions.push(pi_dir().join("fixture-extension.ts")); + } + let runtime = pi_dir().join("node_modules/@arcships/rutis-runtime"); + if !runtime.exists() + || !pi_dir() + .join("node_modules/@earendil-works/pi-coding-agent") + .exists() + { + return Err(format!("run `npm ci` in {} first", pi_dir().display()).into()); + } + let project = tempfile::tempdir()?; + // Extension paths are canonical already; from here on, relative paths and + // `bash` act in the project the extensions see as `ctx.cwd`. + std::env::set_current_dir(project.path())?; + std::fs::write(project.path().join("README.md"), "# demo project\n")?; + + let root = Ctx::root()?; + let bridge = RutisBridge::install(&root)?; + let mut catalog = ServiceCatalog::new(); + catalog.register_shared("yoagent"); + let node = LocalRuntime::node(&runtime, pi_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)); + + let patches: Vec = serde_json::from_value(json!([{ "insert": [{ + "id": "pi", + "name": pi_dir().join("pi-extensions-adapter.ts"), + "config": { + "extensions": extensions, + "cwd": project.path(), + // yoagent built-ins left out, so pi tools of the same name are the ones that run. + "withoutBuiltins": without, + }, + }] }]))?; + let report = loader + .reconcile(vec![Layer::new("app", patches)], None) + .await?; + if !report.failures.is_empty() { + return Err(format!("the adapter 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 seen = Arc::new(Mutex::new(Vec::new())); + let env_file = project.path().join(".env"); + let mut agent = if live { + Agent::from_config(ModelConfig::deepseek("deepseek-flash", "DeepSeek Flash")) + .with_api_key(deepseek_key()?) + } else { + prompt = "(scripted)".into(); + let script = MockProvider::new(vec![ + MockResponse::ToolCalls(vec![ + MockToolCall { + provider_metadata: None, + name: "write_file".into(), + arguments: json!({"path": env_file, "content": "TOKEN=1"}), + }, + // Harmless if a policy lets it through; pi's permission-gate + // flags `sudo`. + MockToolCall { + provider_metadata: None, + name: "bash".into(), + arguments: json!({"command": "echo sudo rm -rf build"}), + }, + ]), + MockResponse::Text("(scripted) done.".into()), + ]); + Agent::from_provider( + Scripted { + inner: script, + seen: seen.clone(), + }, + ModelConfig::mock(), + ) + } + .with_system_prompt(format!( + "You are a coding agent working in {}. Be brief.", + project.path().display() + )) + .with_tools( + default_tools() + .into_iter() + .filter(|tool| !without.iter().any(|name| name == tool.name())) + .collect(), + ) + .with_extension(bridge.extension()); + + 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 + { + let args: String = args.to_string().chars().take(200).collect(); + println!("> {tool_name} {args}"); + } + } + }); + tokio::time::timeout( + Duration::from_secs(300), + agent.prompt_with_sender(prompt, tx), + ) + .await + .map_err(|_| "the run did not finish within 300 s")?; + printer.await?; + + if let Some((tools, note)) = seen.lock().unwrap().first() { + println!("tools offered: {}", tools.join(", ")); + if let Some((_, added)) = note.split_once("(scripted)") { + if !added.trim().is_empty() { + println!("note: {}", added.trim()); + } + } + } + for message in agent.messages() { + match message { + AgentMessage::Llm(Message::ToolResult { + tool_name, + content, + is_error, + .. + }) => { + let text: String = text_of(content).chars().take(300).collect(); + let mark = if *is_error { "error" } else { "ok" }; + println!("[{tool_name}: {mark}] {text}"); + } + AgentMessage::Llm(Message::Assistant { + content, + error_message: Some(e), + .. + }) if text_of(content).is_empty() => println!("error: {e}"), + AgentMessage::Llm(Message::Assistant { content, .. }) => { + let text = text_of(content); + if !text.trim().is_empty() { + println!("assistant: {text}"); + } + } + _ => {} + } + } + let written = env_file.exists(); + println!(".env written: {written}"); + let offered = seen.lock().unwrap().first().map(|(t, _)| t.clone()); + let results: Vec<(String, String, bool)> = agent + .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(); + tokio::time::timeout(Duration::from_secs(10), root.shutdown()) + .await + .map_err(|_| "the plugins did not shut down within 10 s")??; + + // Scripted with the fixture: its tools were offered, and its policy denied + // the write (not a failed run or an unavailable handler). + if fixture && !live { + if written { + return Err("the fixture's policy should have blocked the write to .env".into()); + } + if !offered.unwrap_or_default().iter().any(|t| t == "pi_echo") { + return Err("the fixture's pi_echo tool was not offered".into()); + } + let denied = results.iter().any(|(tool, text, is_error)| { + tool == "write_file" && *is_error && text.contains("is protected") + }); + if !denied { + return Err(format!("the fixture's policy did not deny the write: {results:?}").into()); + } + if !results.iter().any(|(tool, _, _)| tool == "bash") { + return Err(format!("the bash call never ran: {results:?}").into()); + } + println!("ok: the fixture's tools were offered and its policy denied the write"); + } + Ok(()) +} diff --git a/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts b/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts new file mode 100644 index 0000000..06eb477 --- /dev/null +++ b/integrations/yoagent-rutis/plugins/pi/fixture-extension.ts @@ -0,0 +1,130 @@ +// A pi extension for the adapter's tests (`tests/pi_test.rs`): written as +// any pi extension is, against pi's own API, with no knowledge of yoagent. +// No network; the slow tool writes `slow.txt` and `session_shutdown` writes +// `shutdown.txt` in `ctx.cwd`. + +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { defineTool, type ExtensionAPI } from '@earendil-works/pi-coding-agent' +import { Type } from 'typebox' + +const echo = defineTool({ + name: 'pi_echo', + label: 'Echo', + description: 'Echo the text back.', + promptGuidelines: ['Use pi_echo to repeat text exactly.'], + parameters: Type.Object({ text: Type.String() }), + async execute(_id, params) { + return { content: [{ type: 'text', text: `pi echo: ${params.text}` }], details: { length: params.text.length } } + }, +}) + +export default function (pi: ExtensionAPI) { + pi.registerTool(echo) + + pi.registerTool({ + name: 'pi_fail', + label: 'Fail', + description: 'Always fails.', + parameters: Type.Object({ why: Type.String() }), + async execute(_id, params) { + throw new Error(`pi failure: ${params.why}`) + }, + }) + + pi.registerTool({ + name: 'pi_slow', + label: 'Slow', + description: 'Waits until cancelled.', + parameters: Type.Object({}), + async execute(_id, _params, signal, _onUpdate, ctx) { + const file = join(ctx.cwd, 'slow.txt') + writeFileSync(file, 'started') + await new Promise((resolve) => { + if (signal?.aborted) return resolve() + signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + writeFileSync(file, `aborted: ${signal?.reason?.name ?? signal?.reason}`) + return { content: [{ type: 'text', text: 'cancelled' }], details: undefined } + }, + }) + + // A picture: text and an image block, pi's shape. + pi.registerTool({ + name: 'pi_picture', + label: 'Picture', + description: 'Shows a picture of a dot.', + parameters: Type.Object({}), + async execute() { + return { + content: [ + { type: 'text', text: 'a dot' }, + { + type: 'image', + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==', + mimeType: 'image/png', + }, + ], + details: undefined, + } + }, + }) + + // A tool registered when the session starts (pi's dynamic-tools pattern). + pi.on('session_start', () => { + pi.registerTool({ + name: 'pi_dynamic', + label: 'Dynamic', + description: 'Registered at session start.', + parameters: Type.Object({}), + async execute() { + return { content: [{ type: 'text', text: 'dynamic ok' }], details: undefined } + }, + }) + }) + + // pi's protected-paths pattern: block writes to .env. + pi.on('tool_call', async (event) => { + if ((event.toolName === 'write' || event.toolName === 'edit') && String(event.input.path).includes('.env')) { + return { block: true, reason: `Path "${event.input.path}" is protected` } + } + // Rewrite in place, as pi documents. + if (event.toolName === 'bash' && event.input.command === 'echo original') { + event.input.command = 'echo rewritten' + } + if (event.toolName === 'edit') { + const edits = event.input.edits as { oldText: string; newText: string }[] + edits[0].newText = edits[0].newText.toUpperCase() + // Two edits: yoagent's edit_file runs one, so the call is denied. + if (String(event.input.path).endsWith('multi.txt')) edits.push({ oldText: 'x', newText: 'y' }) + } + return undefined + }) + + // A content edit that keeps the picture and adds a caption. + pi.on('tool_result', async (event) => + event.toolName === 'pi_picture' ? { content: [...event.content, { type: 'text', text: 'captioned' }] } : undefined, + ) + + // Redaction: results are chained edits. + pi.on('tool_result', async (event) => { + const text = event.content.map((block) => (block.type === 'text' ? block.text : '')).join('') + if (text.includes('SECRET')) { + return { content: [{ type: 'text', text: text.replaceAll('SECRET', '[redacted]') }] } + } + return undefined + }) + + // Guidance added to the system prompt. + pi.on('before_agent_start', async (event) => ({ + systemPrompt: `${event.systemPrompt}\n\nFixture rules: answer in one line.`, + })) + + // When the adapter unloads. + pi.on('session_shutdown', (_event, ctx) => { + writeFileSync(join(ctx.cwd, 'shutdown.txt'), 'bye') + }) + + // App-level: reported by the adapter as not available. + pi.registerCommand('fixture', { description: 'A command', handler: async () => {} }) +} diff --git a/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts new file mode 100644 index 0000000..a4e36e5 --- /dev/null +++ b/integrations/yoagent-rutis/plugins/pi/fixture-extra.ts @@ -0,0 +1,154 @@ +// A second pi extension for `tests/pi_test.rs`, loaded after +// `fixture-extension.ts`: the less common paths. Markers in the arguments +// (`echo bounded`, `echo BREAK`, `echo stop-now`) select a case. + +import { type ExtensionAPI } from '@earendil-works/pi-coding-agent' +import { Type } from 'typebox' + +export default function (pi: ExtensionAPI) { + // Registered second: pi keeps the first registration of a name. + pi.registerTool({ + name: 'pi_echo', + label: 'Echo (second)', + description: 'Never offered: the first registration wins.', + parameters: Type.Object({ text: Type.String() }), + async execute() { + return { content: [{ type: 'text', text: 'second echo' }], details: undefined } + }, + }) + + // Not activated on registration, so not offered. + pi.registerTool({ + name: 'pi_inactive', + label: 'Inactive', + description: 'defaultActive: false', + defaultActive: false, + parameters: Type.Object({}), + async execute() { + return { content: [{ type: 'text', text: 'inactive ran' }], details: undefined } + }, + }) + + // Returns an error instead of throwing. + pi.registerTool({ + name: 'pi_soft_error', + label: 'Soft error', + description: 'Returns isError.', + parameters: Type.Object({}), + async execute() { + return { content: [{ type: 'text', text: 'soft failure' }], details: undefined, isError: true } + }, + }) + + // Overrides pi's built-in `edit`; accepts `edits` as a JSON string, as pi's own edit does. + pi.registerTool({ + name: 'edit', + label: 'edit (override)', + description: 'Edit a file (override).', + parameters: Type.Object({ + path: Type.String(), + edits: Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() })), + }), + // In place, as pi's own edit tool does. + prepareArguments(args: unknown) { + const a = args as { path: string; edits: unknown } + if (typeof a.edits === 'string') a.edits = JSON.parse(a.edits) + return a + }, + async execute(_id, params) { + return { content: [{ type: 'text', text: `edit override: ${JSON.stringify(params)}` }], details: undefined } + }, + }) + + // Nested calls through ctx.executeTool: a pi tool, one a policy blocks, and + // one of yoagent's tools (not reachable). Records each outcome. + pi.registerTool({ + name: 'pi_compose', + label: 'Compose', + description: 'Calls other tools.', + parameters: Type.Object({}), + async execute(_id, _params, _signal, _onUpdate, ctx) { + const callable = ctx.tools.map((t: { name: string }) => t.name) + const outcomes = [] + for (const [name, args] of [ + ['pi_echo', { text: 'nested SECRET' }], + ['pi_echo', { text: 'boom' }], + ['bash', { command: 'echo hi' }], + // A throw: its error text still goes through the redaction. + ['pi_fail', { why: 'SECRET leaked' }], + // No arguments: an empty object, as in pi. + ['pi_dynamic', undefined], + ] as const) { + const o = await ctx.executeTool(name, args) + const text = o.result.content.map((b: { text?: string }) => b.text ?? '').join('') + outcomes.push(`${o.toolCall.id} ${name} ${o.isError ? 'error' : 'ok'}: ${text}`) + } + return { + content: [{ type: 'text', text: `callable: ${callable.includes('pi_echo')}\n${outcomes.join('\n')}` }], + details: undefined, + } + }, + }) + + // Records and shows its result through pi's session API, as pi video tools do. + pi.registerTool({ + name: 'pi_render', + label: 'Render', + description: 'Renders, then records the job.', + parameters: Type.Object({}), + async execute(_id, _params, _signal, _onUpdate, ctx) { + pi.appendEntry('render:last-job', { path: '/tmp/out.mp4' }) + pi.sendMessage({ customType: 'render_result', content: [{ type: 'text', text: 'Rendered /tmp/out.mp4' }], display: true }) + // Read back the way pi stores them: a custom entry and a custom_message entry. + const all = ctx.sessionManager.getEntries() as { type: string; customType?: string }[] + const entry = all.filter((e) => e.type === 'custom' && e.customType === 'render:last-job').length + const message = all.filter((e) => e.type === 'custom_message' && e.customType === 'render_result').length + return { content: [{ type: 'text', text: `rendered; entries ${entry}, messages ${message}` }], details: undefined } + }, + }) + + pi.on('tool_call', async (event) => { + if (event.toolName === 'pi_echo' && event.input.text === 'boom') throw new Error('policy crashed') + // yoagent's search, as pi's grep: include is pi's glob, and an unset + // case_sensitive is pi's ignoreCase: true (yoagent searches case-insensitively). + if (event.toolName === 'grep' && event.input.glob === '*.md' && event.input.ignoreCase === true) { + event.input.glob = '*.txt' + } + // yoagent's list_files, as pi's find: the required pattern defaults to '*'. + if (event.toolName === 'find' && event.input.pattern === '*') event.input.path = `${event.input.path}/inner` + // A field yoagent's bash does not have: denied, not silently dropped. + if (event.toolName === 'bash' && event.input.command === 'echo bounded') event.input.timeout = 30 + if (event.toolName === 'bash' && event.input.command === 'echo stop-now') { + return { block: true, reason: 'stopping the run', terminate: true } + } + return undefined + }) + + // Scoped to pi_fail: shows a nested call's thrown error reached tool_result. + pi.on('tool_result', async (event) => + event.toolName === 'pi_fail' && event.isError ? { content: [{ type: 'text', text: 'pi_fail error seen' }] } : undefined, + ) + + // A details-only edit must keep the content (images included). + pi.on('tool_result', async (event) => (event.toolName === 'read' ? { details: { seen: true } } : undefined)) + // A redaction that fails: the result is withheld. + pi.on('tool_result', async (event) => { + const text = event.content.map((block) => (block.type === 'text' ? block.text : '')).join('') + if (text.includes('BREAK')) throw new Error('redaction crashed') + return undefined + }) + + // The crashing and the prompt-replacing handlers are skipped; the + // message-returning one loses only its message; the first fixture's + // addition and the last handler's still count. + pi.on('before_agent_start', async () => { + throw new Error('prompt hook crashed') + }) + pi.on('before_agent_start', async (event) => ({ + message: { customType: 'x', content: 'injected', display: false }, + systemPrompt: `${event.systemPrompt}\n\nMessage-handler rules: kept.`, + })) + pi.on('before_agent_start', async () => ({ systemPrompt: 'a whole new prompt' })) + // After the failing ones: still counts. + pi.on('before_agent_start', async (event) => ({ systemPrompt: `${event.systemPrompt}\n\nExtra rules: last.` })) +} diff --git a/integrations/yoagent-rutis/plugins/pi/package-lock.json b/integrations/yoagent-rutis/plugins/pi/package-lock.json new file mode 100644 index 0000000..ff324fc --- /dev/null +++ b/integrations/yoagent-rutis/plugins/pi/package-lock.json @@ -0,0 +1,2072 @@ +{ + "name": "yoagent-rutis-pi", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yoagent-rutis-pi", + "dependencies": { + "@arcships/rutis": "0.7.0", + "@arcships/rutis-runtime": "0.7.0", + "@earendil-works/pi-ai": "1.1.0", + "@earendil-works/pi-coding-agent": "1.1.0", + "@earendil-works/pi-tui": "1.1.0", + "jiti": "2.7.0", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.129.0.tgz", + "integrity": "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "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/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz", + "integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.35.0", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz", + "integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz", + "integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz", + "integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-login": "^3.972.79", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz", + "integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz", + "integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-ini": "^3.973.17", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz", + "integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz", + "integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/token-providers": "3.1138.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1138.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz", + "integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz", + "integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.35.tgz", + "integrity": "sha512-a8xilRoRaalvSPZdfrs0VY3/BPc0uYhXW56Z/k6nmZ5Vk430LHeowf9APIPq8utM3tMJSNGSSuJMlB9YrPsN+A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.30.tgz", + "integrity": "sha512-B6gvZlcnRBNWraKgyEjKsbhv+VjtbyHmpU7hmtTHzXpDSxHPpCJnYLpglEOOsF+SFne20DbZC5IVC1aNr2Pb4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.54.tgz", + "integrity": "sha512-bhESdru8u8KosziH8jcVta/NM/dNreeRz2+2aU86si5bfBYOiJ6IiqD+9U4URUcU5WJO3Yw+jO3GLDIHlscXMQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz", + "integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz", + "integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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/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/@earendil-works/chord": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-1.1.0.tgz", + "integrity": "sha512-gsHzKfyQ3t0ZIQ3cLBjJ5Vqy1TYnFyruHIV3g3BwmulCDYe5BW4yT9vaJk9QzN6YANILIO75uzuq+Gdxtzz1og==", + "license": "MIT", + "dependencies": { + "esbuild": "0.28.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.1.0.tgz", + "integrity": "sha512-aX1KZomNCPmwYnXa3OivF3VYLJ+WPUkIJlEIZTgwdOZdY/+oToWTQ334WYpQeOz9POpiYFNMJLPwIhXQ4e48kg==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^1.1.0", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.1.0.tgz", + "integrity": "sha512-1T7LAkc/5Bvc0v6w4vAGVdCrli0o/E0pEmYKTnixu95vSFArBjvbhS/G4ZwI0RUePgf0Imcu0VyqlM4EcXxqfw==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.129.0", + "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@earendil-works/pi-telemetry": "^1.1.0", + "@google/genai": "2.21.0", + "@smithy/node-http-handler": "4.12.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "openai": "7.19.0", + "partial-json": "0.1.7", + "typebox": "1.3.27" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-codemode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.1.0.tgz", + "integrity": "sha512-8Asc2AzhoNaXS1snmRFa96OhcWIlFM5k1Kuiz6p2DvIYgi5QHQccRAmDVY9oO92TzWrizpvQpconZGQ7JhlI7Q==", + "license": "MIT", + "dependencies": { + "quickjs-wasi": "3.6.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.1.0.tgz", + "integrity": "sha512-SeEi/4hdcHNgA9UWlefZl7ZZpm3dzi2OoxNjDHsBJ9o298LNOtbL4DGKgitlEj6uCTccvtw6f2hlCkTPVJ2RXg==", + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^1.1.0", + "@earendil-works/pi-agent-core": "^1.1.0", + "@earendil-works/pi-ai": "^1.1.0", + "@earendil-works/pi-codemode": "^1.1.0", + "@earendil-works/pi-mcp": "^1.1.0", + "@earendil-works/pi-tui": "^1.1.0", + "@silvia-odwyer/photon-node": "0.3.4", + "brace-expansion": "5.0.12", + "chalk": "6.0.0", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "grok-mermaid": "0.2.3", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.8", + "jiti": "2.7.0", + "minimatch": "10.2.6", + "proper-lockfile": "4.1.2", + "quickjs-wasi": "3.6.2", + "semver": "7.8.5", + "typebox": "1.3.27", + "undici": "8.10.2", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-mcp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.1.0.tgz", + "integrity": "sha512-xGKwvu3SvVTeoIx8Z7UGoJprB4VK20wKORMhufcWGx61fIauhu3J+UqfB5eeoF1oE/geYBA598K6nh1SdABQkg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.1.0.tgz", + "integrity": "sha512-8gAK05/2pPozZZz6hCLOi8x2vsqzvsmO9gG92kbvuvTPm0qMHaFlaXX98ndXsrOi5pmzha7vNLguuG4+0Rgxjw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.1.0.tgz", + "integrity": "sha512-v7wkS0y2ErZvZkSfemqd9RrBZJ5x6p8Ujsv7NdJj01IXJyFM0kNL6rMdcKcvTe7EVTd6ugvDB9VPZSoaBF9QxQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.11" + }, + "engines": { + "node": ">=22.19.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/@google/genai": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@smithy/core": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz", + "integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "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/@types/node": { + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/chalk": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "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/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "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/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/grok-mermaid": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.3.tgz", + "integrity": "sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "18.0.11", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz", + "integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/quickjs-wasi": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.6.2.tgz", + "integrity": "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==", + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "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/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "license": "MIT" + }, + "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/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/integrations/yoagent-rutis/plugins/pi/package.json b/integrations/yoagent-rutis/plugins/pi/package.json new file mode 100644 index 0000000..bc49745 --- /dev/null +++ b/integrations/yoagent-rutis/plugins/pi/package.json @@ -0,0 +1,18 @@ +{ + "name": "yoagent-rutis-pi", + "private": true, + "description": "The pi extensions adapter for yoagent-rutis: pi coding-agent extensions' tools and tool policies as a yoagent handler, in rutis's Node runtime. pi's packages are installed so extensions' imports (helpers, TypeBox) resolve to the real thing.", + "type": "module", + "engines": { + "node": ">=24" + }, + "dependencies": { + "@arcships/rutis": "0.7.0", + "@arcships/rutis-runtime": "0.7.0", + "@earendil-works/pi-ai": "1.1.0", + "@earendil-works/pi-coding-agent": "1.1.0", + "@earendil-works/pi-tui": "1.1.0", + "jiti": "2.7.0", + "typebox": "1.3.27" + } +} diff --git a/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts new file mode 100644 index 0000000..d0f1f49 --- /dev/null +++ b/integrations/yoagent-rutis/plugins/pi/pi-extensions-adapter.ts @@ -0,0 +1,997 @@ +// pi extensions in yoagent: the tools and tool policies of pi coding-agent +// extensions (https://github.com/earendil-works/pi), offered to yoagent +// agents through the yoagent-rutis bridge. +// +// pi extensions are TypeScript modules written against pi's `ExtensionAPI`. +// This adapter loads them with pi's own loader (so their imports — TypeBox, +// `defineTool`, pi's helpers — resolve to pi's real packages, installed +// here), then maps the part of the API that belongs to an agent loop onto +// one yoagent handler. Load it as a rutis-loader row of a Node runtime whose +// package.json is this directory's, with `yoagent` shared in the loader's +// catalog, and list the extensions in its config (files, or directories with +// an `index.ts` / `index.js`; relative paths resolve against `cwd`): +// +// { "name": "", "config": { "extensions": ["./my-ext.ts"], "cwd": "/repo" } } +// +// What maps (following pi 1.1.0's own runner and agent loop): +// pi.registerTool a yoagent tool, offered while pi would activate it +// (exposure `direct` / `model-only`, `defaultActive` +// not false, active under `setActiveTools`; the +// first registration of a name wins). Arguments go +// through `prepareArguments` and pi's validation +// before any policy sees them; `execute` gets the +// arguments the policies left, and only those: a +// call another handler rewrote afterwards is not +// run. A throw or `isError` is an error result; +// text and image blocks pass through as they are +// (pi's shape is yoagent's). +// `ctx.executeTool` calls another pi tool the way pi +// does (prepared, validated, judged by `tool_call` +// with `parentToolCallId`, results through +// `tool_result`; never rejects); yoagent's own tools +// are not reachable from it. `ctx.tools` lists the +// callable ones. +// A tool that overrides one of pi's built-ins +// (`read`, `edit`, ...) makes the adapter deny +// yoagent's counterpart (`read_file`, ...), so the +// model cannot go around it. A tool named exactly +// like a yoagent built-in (pi's sandboxed `bash`) +// refuses the load unless the host says it left +// that built-in out (`withoutBuiltins`). +// pi.setActiveTools an allowlist over the tools pi knows (yoagent's +// built-ins under pi's names, the extensions' +// tools): those outside it are not offered and +// their calls are denied; tools pi does not know +// (MCP, sub-agents, the host's own) are not +// affected, as in pi. A tool registered later that +// pi would activate joins it, as pi's registry +// refresh does. `getActiveTools` / `getAllTools` +// answer from the same view. +// on("tool_call") `before_tool`, for every call of the run. yoagent's +// built-ins are judged under pi's names (TOOL_NAMES), +// their arguments translated both ways and a +// relative `path` resolved against `cwd` first, so +// the tool acts where the policy looked (built-ins +// only). `{ block }` denies (`terminate: true` also +// stops the run at its next model request — stricter +// than pi, which ends only a batch whose results all +// set it); in-place changes to +// `event.input` rewrite the arguments (a field the +// yoagent tool does not have denies the call); a +// handler that throws blocks, as in pi. +// on("tool_result") `after_tool`: changes to content, details and +// isError are chained; only the fields a handler +// set are applied (a replaced content keeps its text +// and image blocks). A handler that throws withholds the result +// — unlike pi, which skips it: a redaction that +// failed must not let the raw output through. +// on("input") `on_input`: `handled` rejects the prompt (it never +// reaches the agent, as in pi); `transform` and a +// handler that throws reject it too (yoagent cannot +// rewrite a prompt; failing closed). +// on("before_agent_start") `before_model`: the handlers run once per run; +// text they add around `event.systemPrompt` is a +// note on the latest user turn of every request of +// the run (yoagent never rewrites the system prompt +// — the prompt cache depends on it). A handler that +// throws, replaces the prompt or changes +// `systemPromptOptions` is skipped with a warning +// (its policy does not apply); the others still +// count. A returned `message` is dropped, the same +// handler's addition kept. +// on("session_start") fired once, when the adapter starts; a handler +// that throws refuses the load (its extension did +// not finish setting up — its policies included). +// on("session_shutdown") when it unloads. +// +// What does not map: no other event is ever fired. Events that would decide +// or rewrite something yoagent does (DECIDING_EVENTS: `context`, +// `message_end`, `before_provider_request`, ...) refuse the load unless +// listed in `allowUnmapped`; the rest (observers such as `agent_end` or +// `tool_execution_*`, pi's session events, the boundary events `turn_end` / +// `agent_before_settle`), commands, shortcuts, flags, renderers, model +// providers, virtual models and MCP servers are reported (a warning, or a +// load failure with `strict`). One registered after load (in a handler or a +// tool) is reported at the next model request or tool call, and a deciding +// one stops the adapter: that run, and every later call, refused. Warnings +// go to the host's logs (`yoagent.log`, else stderr). `pi.appendEntry` and +// `pi.sendMessage` are recorded in the adapter's in-memory session (readable +// through `ctx.sessionManager`; a displayed message is also logged; a message +// never starts a turn); other runtime actions (`pi.sendUserMessage`, +// `pi.setModel`, ...) throw "not available in yoagent". `ctx.hasUI` is false and `ctx.ui` behaves as in +// pi's print mode: `confirm` answers false, `select` and `input` nothing, so +// a policy that would ask the user denies instead. + +import { isAbsolute, resolve } from 'node:path' +import { definePlugin } from '@arcships/rutis' +import type { Cancellable, ContentBlock, ToolCall, ToolOutput, ToolResult, ToolSpec, Yoagent } from '../yoagent.d.ts' + +export interface Config { + /** The handler's name in the bridge (unique across the host's plugins). */ + name?: string + /** pi extensions (files, or directories with an `index.ts` / `index.js`), loaded in order. */ + extensions: string[] + /** The project directory extensions see as `ctx.cwd`; relative paths resolve against it (default: the runtime's). */ + cwd?: string + /** yoagent tool name → the pi tool name policies see (merged over TOOL_NAMES). */ + toolNames?: Record + /** yoagent built-ins the host left out, so pi tools of the same name are the ones that run. */ + withoutBuiltins?: string[] + /** Deciding events (DECIDING_EVENTS) the host accepts going unenforced. */ + allowUnmapped?: string[] + /** Fail loading when an extension registers anything else that does not map. */ + strict?: boolean +} + +/** + * yoagent's built-in tools under the names pi's own built-ins have, so a pi + * policy written for `write` or `bash` judges yoagent's `write_file` and + * `bash`. Arguments are translated both ways (ARGS). + */ +const TOOL_NAMES: Record = { + bash: 'bash', + read_file: 'read', + write_file: 'write', + edit_file: 'edit', + search: 'grep', + list_files: 'find', +} + +/** The arguments yoagent's built-ins take: a rewrite to any other field cannot apply. */ +const YOAGENT_KEYS = new Map( + Object.entries({ + bash: ['command'], + read_file: ['path', 'offset', 'limit'], + write_file: ['path', 'content'], + edit_file: ['path', 'old_text', 'new_text'], + search: ['pattern', 'path', 'include', 'case_sensitive'], + list_files: ['path', 'pattern', 'max_depth'], + }).map(([tool, keys]) => [tool, new Set(keys)]), +) + +type Args = Record + +/** + * yoagent ↔ pi argument shapes, where they differ, keyed by the yoagent + * built-in: only those calls are translated (a pi tool that is itself named + * `edit` keeps its arguments as they are). + */ +const ARGS = new Map( + Object.entries({ + edit_file: { + toPi: ({ old_text, new_text, ...rest }: Args) => ({ ...rest, edits: [{ oldText: old_text, newText: new_text }] }), + fromPi: ({ edits, ...rest }: Args) => { + const list = edits as { oldText?: unknown; newText?: unknown }[] | undefined + if (!Array.isArray(list) || list.length !== 1) { + throw new Error("a pi extension rewrote `edits` into something other than one edit, which yoagent's edit_file cannot run") + } + return { ...rest, old_text: list[0].oldText, new_text: list[0].newText } + }, + }, + search: { + toPi: ({ include, case_sensitive, ...rest }: Args) => ({ + ...rest, + ...(include === undefined ? {} : { glob: include }), + // yoagent's search is case-insensitive unless asked; pi's grep is case-sensitive unless asked. + ignoreCase: !(case_sensitive ?? false), + }), + fromPi: ({ glob, ignoreCase, ...rest }: Args) => ({ + ...rest, + ...(glob === undefined ? {} : { include: glob }), + case_sensitive: ignoreCase === false, + }), + }, + list_files: { + // pi's `find` requires a pattern; yoagent's list_files has an optional one. + toPi: ({ pattern, ...rest }: Args) => ({ ...rest, pattern: pattern ?? '*' }), + fromPi: ({ pattern, ...rest }: Args) => (pattern === '*' ? rest : { ...rest, pattern }), + }, + }), +) + +/** What `before_agent_start` handlers see as `event.systemPrompt`. */ +const PROMPT_MARK = '\u0000yoagent-system-prompt\u0000' + +/** The events this adapter fires. */ +const MAPPED_EVENTS = new Set(['tool_call', 'tool_result', 'input', 'before_agent_start', 'session_start', 'session_shutdown']) + +/** Unfired events whose handlers would decide or rewrite something yoagent does: they refuse the load. */ +const DECIDING_EVENTS = new Set([ + 'context', + 'context_with_system', + 'message_end', + 'before_provider_request', + 'before_provider_headers', +]) + +/** pi runtime actions with no yoagent counterpart. */ +const UNSUPPORTED_ACTIONS = [ + 'sendUserMessage', + 'setSessionName', + 'getSessionName', + 'setLabel', + 'getSettings', + 'getCommands', + 'getThinkingLevel', + 'setThinkingLevel', +] + +/** Any theme call returns its text unstyled (`theme.fg('dim', text)` → text). */ +const PLAIN_THEME = new Proxy({} as Record, { + get: (_target, key) => + key === 'then' ? undefined : key === 'name' ? 'plain' : (...args: unknown[]) => args[args.length - 1], +}) + +/** Where diagnostics go: the host's logs through `yoagent.log` once `apply` has the service, else stderr. */ +let report = (level: 'error' | 'warn' | 'info' | 'debug', message: string) => { + console.warn(message) +} + +/** pi's print-mode UI (its `noOpUIContext`): nothing to show, no one to ask. */ +const NO_UI = new Proxy( + { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + editor: async () => undefined, + custom: async () => undefined, + notify: (message: string, level?: string) => + report(level === 'error' ? 'error' : level === 'warning' ? 'warn' : 'info', `[pi ${level ?? 'info'}] ${message}`), + onTerminalInput: () => () => {}, + getEditorText: () => '', + getAllThemes: () => [], + getTheme: () => undefined, + getToolsExpanded: () => false, + setTheme: () => ({ success: false, error: 'UI not available' }), + theme: PLAIN_THEME, + } as Record, + { + // Every other UI call (setStatus, setWidget, ...) does nothing, as in pi; + // `then` stays undefined so the object is not mistaken for a promise. + get: (target, key) => (key in target ? target[key as string] : key === 'then' ? undefined : () => undefined), + }, +) + +interface Block { + type: string + text?: string + data?: string + mimeType?: string +} + +interface PiTool { + name: string + label?: string + description?: string + promptGuidelines?: string[] + parameters: Record + exposure?: string + defaultActive?: boolean + prepareArguments?: (args: unknown) => unknown + execute(id: string, params: unknown, signal: AbortSignal | undefined, onUpdate: unknown, ctx: unknown): Promise<{ + content?: Block[] + details?: unknown + isError?: boolean + }> +} + +interface PiExtension { + path: string + handlers: Map unknown)[]> + tools: Map + commands: Map + flags: Map + shortcuts: Map + messageRenderers: Map + toolRenderers?: unknown[] + entryRenderers?: Map +} + +interface PiRuntime { + pendingProviderRegistrations?: { name: string; extensionPath: string }[] + pendingNativeProviderRegistrations?: { provider: { id: string }; extensionPath: string }[] + pendingVirtualModelRegistrations?: { definition: { provider: string; id: string }; extensionPath: string }[] + mcpServers?: { list(): { name: string; extensionPath?: string }[] } + [action: string]: unknown +} + +/** + * pi's loader, by file: the package exports only `discoverAndLoadExtensions`, + * which also loads `/.pi/extensions` and `~/.pi/agent/extensions`. + */ +async function piLoader(): Promise<{ + loadExtensions(paths: string[], cwd: string): Promise<{ + extensions: PiExtension[] + errors: { path: string; error: string }[] + warnings?: { path: string; warning: string }[] + runtime: PiRuntime + }> +}> { + const index = import.meta.resolve('@earendil-works/pi-coding-agent') + return import(new URL('./core/extensions/loader.js', index).href) +} + +const short = (path: string) => path.split('/').pop() ?? path + +function text(content: Block[] | undefined): string { + return (content ?? []).map((block) => (block.type === 'text' ? (block.text ?? '') : `[${block.type} block]`)).join('\n') +} + +/** pi's content blocks as yoagent's (the same shape for text and images); any other block is named in text. */ +function blocks(content: Block[] | undefined): ContentBlock[] { + return (content ?? []).map((block): ContentBlock => { + if (block.type === 'text') return { type: 'text', text: block.text ?? '' } + if (block.type === 'image' && block.data && block.mimeType) { + return { type: 'image', data: block.data, mimeType: block.mimeType } + } + return { type: 'text', text: `[${block.type} block]` } + }) +} + +/** + * JSON with object keys sorted and non-integers at 15 significant digits: + * arguments cross serde_json, which may reorder keys and parse a float's + * shortest form one unit off. + */ +function canon(value: unknown): string { + return JSON.stringify(value, (_key, v) => { + if (typeof v === 'number' && !Number.isInteger(v)) return Number(v.toPrecision(15)) + return v && typeof v === 'object' && !Array.isArray(v) + ? Object.fromEntries(Object.entries(v as Args).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) + : v + }) +} + +const message = (error: unknown) => (error instanceof Error ? error.message : String(error)) + +export default definePlugin({ + inject: ['yoagent'], + config: { + type: 'object', + required: ['extensions'], + properties: { + name: { type: 'string' }, + extensions: { type: 'array', items: { type: 'string' } }, + cwd: { type: 'string' }, + toolNames: { type: 'object', additionalProperties: { type: 'string' } }, + withoutBuiltins: { type: 'array', items: { type: 'string' } }, + allowUnmapped: { type: 'array', items: { type: 'string' } }, + strict: { type: 'boolean' }, + }, + }, + async apply(ctx, config) { + const yoagent = ctx.use('yoagent') + // rutis gives every service method a stand-in, one that throws when the + // host lacks it: so `log` is called, and a throw or a rejection falls back. + report = (level, message) => { + try { + yoagent.log?.(level, message)?.catch(() => console.warn(message)) + } catch { + console.warn(message) + } + } + const cwd = config.cwd ?? process.cwd() + const names = new Map(Object.entries({ ...TOOL_NAMES, ...config.toolNames })) + const withoutBuiltins = new Set(config.withoutBuiltins ?? []) + const allowUnmapped = new Set(config.allowUnmapped ?? []) + + const { SessionManager } = await import('@earendil-works/pi-coding-agent') + const { validateToolArguments } = await import('@earendil-works/pi-ai') + const loaded = await (await piLoader()).loadExtensions(config.extensions, cwd) + if (loaded.errors.length > 0) { + throw new Error(`pi extensions failed to load: ${loaded.errors.map((e) => `${e.path}: ${e.error}`).join('; ')}`) + } + for (const w of loaded.warnings ?? []) report('warn', `[pi] ${short(w.path)}: ${w.warning}`) + const extensions = loaded.extensions + const runtime = loaded.runtime + + /** + * Set when something that would have to be enforced turns up after load: + * from then on every tool call is denied, every prompt rejected, every + * run stopped. + */ + let refusal: string | undefined + + const sessionManager = SessionManager.inMemory(cwd) + const context = (signal?: AbortSignal, systemPrompt = '') => ({ + ui: NO_UI, + mode: 'print', + hasUI: false, + cwd, + sessionManager, + modelRegistry: undefined, + model: undefined, + scopedModels: [], + signal, + isIdle: () => signal === undefined, + // pi's own default is true; this adapter loads only the files it is given, never a project's `.pi`. + isProjectTrusted: () => false, + hasPendingMessages: () => false, + getContextUsage: () => undefined, + getSystemPrompt: () => systemPrompt, + abort: () => { + throw new Error('ctx.abort() is not available in yoagent') + }, + shutdown: () => { + throw new Error('ctx.shutdown() is not available in yoagent') + }, + compact: () => { + throw new Error('ctx.compact() is not available in yoagent') + }, + }) + /** + * The pi tools `ctx.executeTool` can call: pi's callable exposures + * (`direct` while available, `codemode`, `deferred`; never `model-only` + * or `hidden`). yoagent's own tools are not reachable from here. + */ + const callable = () => { + const offered = available() + return new Map( + [...registered()].filter(([name, tool]) => { + const exposure = tool.exposure ?? 'direct' + return exposure === 'direct' ? offered.has(name) : exposure === 'codemode' || exposure === 'deferred' + }), + ) + } + + type Outcome = { + toolCall: { id: string; name: string; arguments: unknown } + result: { content: Block[]; details: unknown } + isError: boolean + durationMs?: number + } + + /** + * A nested call, as pi's `executeTool` makes it: prepared and validated, + * judged by the `tool_call` handlers (with `parentToolCallId`), run, and + * passed through the `tool_result` handlers. Never rejects: an unknown + * tool, a validation error, a block or a throw is `isError`. + */ + const executeNested = async ( + runId: string, + parentId: string, + id: string, + name: string, + args: unknown, + signal: AbortSignal, + depth: number, + ): Promise => { + const failed = (text: string, input: unknown = args): Outcome => ({ + toolCall: { id, name, arguments: input }, + result: { content: [{ type: 'text', text }], details: {} }, + isError: true, + }) + checkUnmapped(false) + checkShadowing(false) + if (refusal) return failed(refusal) + if (depth >= 8) return failed('nested tool calls are limited to 8 levels') + const tool = callable().get(name) + if (!tool) return failed(`Tool ${name} not found (yoagent's own tools cannot be called from a pi tool)`) + let input: Args + try { + // As pi: missing arguments are an empty object. + const copy = structuredClone(args ?? {}) + const prepared = tool.prepareArguments ? tool.prepareArguments(copy) : copy + input = validateToolArguments(tool as never, { name, arguments: prepared } as never) as Args + } catch (error) { + return failed(message(error)) + } + for (const { ext, fn } of handlers('tool_call')) { + const event = { type: 'tool_call', toolCallId: id, parentToolCallId: parentId, toolName: name, input } + let result: { block?: boolean; reason?: string; terminate?: boolean } | undefined + try { + result = (await fn(event, context(signal))) as typeof result + } catch (error) { + return failed(`pi extension ${short(ext.path)} failed: ${message(error)}`, input) + } + if (result?.block) { + const reason = result.reason ?? `blocked by pi extension ${short(ext.path)}` + if (result.terminate === true) terminated.set(runId, reason) + return failed(reason, input) + } + } + const started = performance.now() + let out: { content?: Block[]; details?: unknown; isError?: boolean } + try { + out = await tool.execute(id, input, signal, undefined, toolContext(signal, runId, id, depth + 1)) + } catch (error) { + // As pi: a throw is an error result, and it still goes through the tool_result handlers. + out = { content: [{ type: 'text', text: message(error) }], details: {}, isError: true } + } + const durationMs = Math.round(performance.now() - started) + const event = { + type: 'tool_result', + toolCallId: id, + parentToolCallId: parentId, + toolName: name, + input, + content: out.content ?? [], + details: out.details, + isError: out.isError === true, + } + for (const { ext, fn } of handlers('tool_result')) { + try { + const edit = (await fn(event, context(signal))) as { content?: Block[]; details?: unknown; isError?: boolean } | undefined + if (edit?.content !== undefined) event.content = edit.content + if (edit?.details !== undefined) event.details = edit.details + if (edit?.isError !== undefined) event.isError = edit.isError + } catch (error) { + // As at the top level: a failed redaction must not let the raw output through. + return failed(`pi tool_result handler ${short(ext.path)} failed, the result is withheld: ${message(error)}`, input) + } + } + return { + toolCall: { id, name, arguments: input }, + result: { content: event.content, details: event.details }, + isError: event.isError, + durationMs, + } + } + + /** A tool's `ctx`: the extension context, plus `tools` and `executeTool` for nested calls. */ + const toolContext = (signal: AbortSignal, runId: string, callId: string, depth = 0) => { + let nested = 0 + return { + ...context(signal), + get tools() { + return [...callable().values()] + }, + executeTool: (name: string, args: unknown, options?: { signal?: AbortSignal }) => + executeNested(runId, callId, `${callId}/${++nested}`, name, args, options?.signal ?? signal, depth), + } + } + + /** Every handler of `event`, in extension load and registration order. */ + const handlers = (event: string) => + extensions.flatMap((ext) => (ext.handlers.get(event) ?? []).map((fn) => ({ ext, fn }))) + + /** Every registered tool, the first registration of a name winning (pi's `getAllRegisteredTools`). */ + const registered = () => { + const byName = new Map() + for (const ext of extensions) { + for (const { definition } of ext.tools.values()) { + if (!byName.has(definition.name)) byName.set(definition.name, definition) + } + } + return byName + } + + // pi.setActiveTools: an allowlist over the tools pi knows — yoagent's built-ins under pi's + // names and the extensions' tools. Other tools (MCP, sub-agents, the host's own) are not pi's + // to activate and stay outside it, as in pi. + let active: Set | undefined + /** Tool names registered when the allowlist was set; one registered later joins it as pi's registry refresh does. */ + let knownAtActivation = new Set() + const activatedOnRegistration = (tool: PiTool) => { + const exposure = tool.exposure ?? 'direct' + return (exposure === 'direct' || exposure === 'model-only') && tool.defaultActive !== false + } + /** The tools pi would offer (its `_applyToolLoadout`): activated on registration, or named in the allowlist and not hidden. */ + const available = () => { + const tools = registered() + if (active) { + for (const [name, tool] of tools) { + if (!knownAtActivation.has(name) && activatedOnRegistration(tool)) active.add(name) + knownAtActivation.add(name) + } + } + return new Map( + [...tools].filter(([name, tool]) => + active ? active.has(name) && (tool.exposure ?? 'direct') !== 'hidden' : activatedOnRegistration(tool), + ), + ) + } + /** Whether a call to `toolName` (a pi name) is outside the allowlist. */ + const inactive = (toolName: string) => + active !== undefined && + !active.has(toolName) && + (registered().has(toolName) || [...names.values()].includes(toolName)) + + // The runtime actions: tool activation is pi's to decide here; the rest has no counterpart. + runtime.setActiveTools = (toolNames: string[]) => { + active = new Set(toolNames) + knownAtActivation = new Set(registered().keys()) + } + runtime.getActiveTools = () => { + // Brings in tools registered since setActiveTools, as pi's registry refresh does on registerTool. + const tools = available() + if (active) return [...active] + const builtins = [...names].filter(([yo]) => !withoutBuiltins.has(yo)).map(([, pi]) => pi) + return [...new Set([...builtins, ...tools.keys()])] + } + runtime.getAllTools = () => { + const tools = registered() + return [ + // yoagent's built-ins as pi's (an extension's tool of the same name takes its place; one + // the host left out is not listed). + ...[...names] + .filter(([yo, pi]) => !withoutBuiltins.has(yo) && !tools.has(pi)) + .map(([yo, pi]) => ({ + name: pi, + description: `yoagent's ${yo}`, + parameters: { type: 'object' }, + exposure: 'direct', + sourceInfo: { path: `builtin:${pi}`, source: 'builtin', scope: 'user', origin: 'top-level' }, + })), + ...[...tools.values()].map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + promptGuidelines: tool.promptGuidelines, + exposure: tool.exposure ?? 'direct', + sourceInfo: { path: 'extension', source: 'extension', scope: 'temporary', origin: 'top-level' }, + })), + ] + } + // Session writes go to the adapter's in-memory session, so a tool that records or shows its + // result this way (pi video tools do, after the paid work) does not fail. They are pi state, + // read back through `ctx.sessionManager`; yoagent's own history is written only by its host. + runtime.appendEntry = (customType: string, data?: unknown) => { + sessionManager.appendCustomEntry(customType, data) + } + runtime.sendMessage = ( + message: { customType: string; content: string | Block[]; display?: boolean; details?: unknown }, + options?: { triggerTurn?: boolean; deliverAs?: string }, + ) => { + // As pi's own runtime stores it: a `custom_message` entry. + sessionManager.appendCustomMessageEntry(message.customType, message.content ?? [], message.display, message.details) + if (message.display) { + const content = message.content + const shown = + typeof content === 'string' ? content : Array.isArray(content) ? text(content) : JSON.stringify(content) + report('info', `[pi ${message.customType}] ${shown}`) + } + if (options?.triggerTurn || options?.deliverAs) { + report('warn', `[pi] ${message.customType}: a message cannot start or join a turn in yoagent; recorded only`) + } + } + for (const action of UNSUPPORTED_ACTIONS) { + runtime[action] = () => { + throw new Error(`pi.${action}() is not available in yoagent`) + } + } + runtime.setModel = () => Promise.reject(new Error('pi.setModel() is not available in yoagent')) + + // What does not map: reported once each; deciding events refuse. + const reported = new Set() + const checkUnmapped = (atLoad: boolean) => { + const deciding: string[] = [] + const ignored: string[] = [] + for (const ext of extensions) { + const own = (what: string) => `${short(ext.path)}: ${what}` + for (const event of ext.handlers.keys()) { + if (MAPPED_EVENTS.has(event)) continue + if (DECIDING_EVENTS.has(event) && !allowUnmapped.has(event)) deciding.push(own(`event "${event}"`)) + else ignored.push(own(`event "${event}"`)) + } + ignored.push( + ...[...ext.commands.keys()].map((c) => own(`command /${c}`)), + ...[...ext.shortcuts.keys()].map((s) => own(`shortcut ${s}`)), + ...[...ext.flags.keys()].map((f) => own(`flag --${f}`)), + ...(ext.messageRenderers.size + (ext.toolRenderers?.length ?? 0) + (ext.entryRenderers?.size ?? 0) > 0 + ? [own('renderers')] + : []), + ...(runtime.pendingProviderRegistrations ?? []) + .filter((p) => p.extensionPath === ext.path) + .map((p) => own(`model provider ${p.name}`)), + ...(runtime.pendingNativeProviderRegistrations ?? []) + .filter((p) => p.extensionPath === ext.path) + .map((p) => own(`model provider ${p.provider.id}`)), + ...(runtime.pendingVirtualModelRegistrations ?? []) + .filter((v) => v.extensionPath === ext.path) + .map((v) => own(`virtual model ${v.definition.provider}/${v.definition.id}`)), + ...(runtime.mcpServers?.list() ?? []) + .filter((s) => s.extensionPath === ext.path) + .map((s) => own(`MCP server ${s.name}`)), + ) + } + const fresh = (list: string[]) => list.filter((what) => !reported.has(what) && reported.add(what)) + const newDeciding = fresh(deciding) + const newIgnored = fresh(ignored) + if (newDeciding.length > 0) { + const text = + `pi extensions handle events yoagent never fires, which would decide or rewrite what the agent does: ` + + `${newDeciding.join('; ')} (list an event in allowUnmapped to accept that it goes unenforced)` + if (atLoad) throw new Error(text) + refusal = `the pi extensions adapter stopped: ${text}` + report('warn', `[pi] ${refusal}`) + } + if (newIgnored.length > 0) { + const text = `not available in yoagent, ignored: ${newIgnored.join('; ')}` + if (atLoad && config.strict) throw new Error(`pi extensions use what does not map — ${text}`) + report('warn', `[pi] ${text}`) + } + } + + // A pi tool named exactly like a yoagent built-in: if the host also installs that one, it is + // the one that runs (yoagent's own tools win the merge), unjudged as the pi tool. + const checkShadowing = (atLoad: boolean) => { + for (const name of available().keys()) { + if (!names.has(name) || withoutBuiltins.has(name) || reported.has(`shadow:${name}`)) continue + reported.add(`shadow:${name}`) + const text = + `a pi extension's tool "${name}" is named like yoagent's built-in "${name}"; if the host installs that ` + + `one too, it runs instead of the pi tool. Leave yoagent's out and list it in withoutBuiltins` + if (atLoad) throw new Error(text) + refusal = `the pi extensions adapter stopped: ${text}` + report('warn', `[pi] ${refusal}`) + } + } + + // Notes from `before_agent_start`, computed once per run. + const notes = new Map>() + // Runs a `{ block, terminate: true }` asked to stop. + const terminated = new Map() + // The arguments the policies approved, per run and call: `call_tool` runs only these. + const judged = new Map() + const callKey = (call: ToolCall) => `${call.run_id}/${call.call_id}` + + const startNote = async (prompt: string, signal: AbortSignal) => { + let systemPrompt = PROMPT_MARK + for (const { ext, fn } of handlers('before_agent_start')) { + const options = new Proxy({} as Record, { + get: () => undefined, + set: () => { + throw new Error('changing systemPromptOptions is not available in yoagent; add text to systemPrompt instead') + }, + }) + const event = { type: 'before_agent_start', prompt, systemPrompt, systemPromptOptions: options } + try { + const result = (await fn(event, context(signal, systemPrompt))) as + | { systemPrompt?: unknown; message?: unknown } + | undefined + if (result?.message !== undefined) { + report('warn', `[pi] ${short(ext.path)} before_agent_start: its message dropped (yoagent cannot inject one)`) + } + if (result?.systemPrompt !== undefined) { + if (typeof result.systemPrompt !== 'string' || !result.systemPrompt.includes(PROMPT_MARK)) { + throw new Error('replaced the system prompt; yoagent only takes text added to it') + } + systemPrompt = result.systemPrompt + } + } catch (error) { + // As in pi: one handler's failure is reported, the others still count. + report('warn', `[pi] ${short(ext.path)} before_agent_start skipped, so it does not apply: ${message(error)}`) + } + } + const added = systemPrompt.split(PROMPT_MARK).map((part) => part.trim()).filter(Boolean) + return added.length > 0 ? added.join('\n\n') : undefined + } + + // Before registering: tools an extension adds at session start are offered from the first run. + const started: string[] = [] + for (const { ext, fn } of handlers('session_start')) { + try { + await fn({ type: 'session_start', reason: 'startup' }, context()) + } catch (error) { + started.push(`${short(ext.path)}: ${message(error)}`) + } + } + // Registered first, so a refusal below still shuts the extensions down. + ctx.effect(async () => { + for (const { ext, fn } of handlers('session_shutdown')) { + try { + await fn({ type: 'session_shutdown', reason: 'quit' }, context()) + } catch (error) { + report('warn', `[pi] ${short(ext.path)} session_shutdown: ${message(error)}`) + } + } + }) + if (started.length > 0) { + // An extension whose setup failed may be missing its policies. + throw new Error(`pi extensions failed in session_start: ${started.join('; ')}`) + } + // After session_start, so what it registered is checked too. + checkUnmapped(true) + checkShadowing(true) + + ctx.effect( + yoagent.register(config.name ?? 'pi-extensions', { + async tools(): Promise { + // Registrations made since load: reported now (a run cannot refuse the load). + checkUnmapped(false) + checkShadowing(false) + if (refusal) return [] + return [...available().values()].map((tool) => ({ + name: tool.name, + label: tool.label ?? null, + description: [tool.description ?? '', ...(tool.promptGuidelines ?? []).map((g) => `- ${g}`)] + .filter(Boolean) + .join('\n'), + parameters: tool.parameters, + })) + }, + + async call_tool(call: ToolCall & Cancellable): Promise { + const tool = available().get(call.tool) + if (!tool) return { text: `pi tool ${call.tool} is not available`, is_error: true } + const approved = judged.get(callKey(call)) + judged.delete(callKey(call)) + if (approved === undefined || canon(call.args) !== approved) { + return { + text: `not run: the arguments of ${call.tool} changed after pi's policies judged them`, + is_error: true, + } + } + try { + const out = await tool.execute(call.call_id, call.args, call.signal, undefined, toolContext(call.signal, call.run_id, call.call_id)) + if (out.isError === true) return { text: text(out.content), details: out.details ?? null, is_error: true } + return { content: blocks(out.content), details: out.details ?? null } + } catch (error) { + return { text: message(error), is_error: true } + } + }, + + async before_tool(call: ToolCall & Cancellable) { + // Registrations made during the run: caught before the next call, not the next run. + checkUnmapped(false) + checkShadowing(false) + if (refusal) return { deny: refusal } + const piTools = available() + const own = piTools.get(call.tool) + const counterpart = names.get(call.tool) + // A yoagent built-in whose pi counterpart an extension overrides: the model must use the override. + if (!own && counterpart && counterpart !== call.tool && piTools.has(counterpart)) { + return { deny: `a pi extension replaces this tool with "${counterpart}"; call "${counterpart}" instead` } + } + const toolName = own ? call.tool : (counterpart ?? call.tool) + if (inactive(toolName)) { + return { deny: `"${toolName}" is not an active tool (a pi extension narrowed them with setActiveTools)` } + } + const policies = handlers('tool_call') + const shape = own ? undefined : ARGS.get(call.tool) + let input: Args + let base: Args = call.args + if (own) { + // As pi's agent loop: prepare, validate, then the policies judge the validated arguments. + try { + // On a copy: pi's own prepareArguments (edit's) mutates its argument in place. + const copy = structuredClone(call.args) + const prepared = own.prepareArguments ? (own.prepareArguments(copy) as Args) : copy + input = validateToolArguments(own as never, { name: own.name, arguments: prepared } as never) as Args + } catch (error) { + return { deny: message(error) } + } + } else { + // A built-in's relative path is resolved where the extensions look (`ctx.cwd`), so the + // tool acts there. Only yoagent's own tools: an MCP tool's `path` may be a repository's. + const builtin = YOAGENT_KEYS.has(call.tool) + if (builtin && policies.length > 0 && typeof base.path === 'string' && !isAbsolute(base.path)) { + base = { ...base, path: resolve(cwd, base.path) } + } + input = structuredClone(shape ? shape.toPi(base) : base) + } + const before = canon(input) + for (const { ext, fn } of policies) { + const event = { type: 'tool_call', toolCallId: call.call_id, toolName, input } + let result: { block?: boolean; reason?: string; terminate?: boolean } | undefined + try { + result = (await fn(event, context(call.signal))) as typeof result + } catch (error) { + // As in pi: a failing tool_call handler blocks the call. + return { deny: `pi extension ${short(ext.path)} failed: ${message(error)}` } + } + if (result?.block) { + const reason = result.reason ?? `blocked by pi extension ${short(ext.path)}` + if (result.terminate === true) terminated.set(call.run_id, reason) + return { deny: reason } + } + } + if (own) { + judged.set(callKey(call), canon(input)) + // Prepared or coerced arguments count as a rewrite too. + return canon(input) === canon(call.args) ? undefined : { args: input } + } + if (canon(input) === before && base === call.args) return + let out: Args + try { + out = shape ? shape.fromPi(input) : input + } catch (error) { + return { deny: message(error) } + } + const keys = YOAGENT_KEYS.get(call.tool) + if (keys) { + for (const [key, value] of Object.entries(out)) { + if (!keys.has(key) && canon(value) !== canon(base[key])) { + return { deny: `a pi extension set "${key}", which yoagent's ${call.tool} does not have` } + } + } + } + return { args: out } + }, + + async after_tool(call: ToolCall & Cancellable, output: ToolOutput) { + if (refusal) throw new Error(refusal) + const editors = handlers('tool_result') + if (editors.length === 0) return + const own = available().has(call.tool) + const shape = own ? undefined : ARGS.get(call.tool) + const event = { + type: 'tool_result', + toolCallId: call.call_id, + toolName: own ? call.tool : (names.get(call.tool) ?? call.tool), + input: shape ? shape.toPi(call.args) : call.args, + // yoagent's text and image blocks have pi's shape. + content: output.content as Block[], + details: output.details, + isError: output.is_error, + } + const changed = { content: false, details: false, isError: false } + const failed: string[] = [] + for (const { ext, fn } of editors) { + try { + const result = (await fn(event, context(call.signal))) as + | { content?: Block[]; details?: unknown; isError?: boolean } + | undefined + if (result?.content !== undefined) { + event.content = result.content + changed.content = true + } + if (result?.details !== undefined) { + event.details = result.details + changed.details = true + } + if (result?.isError !== undefined) { + event.isError = result.isError + changed.isError = true + } + } catch (error) { + failed.push(`${short(ext.path)}: ${message(error)}`) + } + } + if (failed.length > 0) { + // Unlike pi (which skips it): a redaction that failed must not let the raw output through. + throw new Error(`pi tool_result handlers failed, the result is withheld: ${failed.join('; ')}`) + } + if (!changed.content && !changed.details && !changed.isError) return + return { + // Only a replaced content is sent back, as blocks (images kept): yoagent's edit replaces every block. + ...(changed.content ? { content: blocks(event.content) } : {}), + ...(changed.details ? { details: event.details ?? null } : {}), + ...(changed.isError ? { is_error: event.isError } : {}), + } + }, + + async on_input(input) { + if (refusal) return { reject: refusal } + for (const { ext, fn } of handlers('input')) { + const event = { type: 'input', text: input.text, source: 'interactive' } + let result: { action?: string } | undefined + try { + result = (await fn(event, context(input.signal))) as typeof result + } catch (error) { + // Unlike pi (which goes on): an input check that failed rejects. + return { reject: `pi extension ${short(ext.path)} failed: ${message(error)}` } + } + if (result?.action === 'handled') return { reject: `handled by pi extension ${short(ext.path)}` } + if (result?.action === 'transform') { + return { reject: `pi extension ${short(ext.path)} would rewrite the prompt, which yoagent cannot do` } + } + } + }, + + async before_model(turn) { + checkUnmapped(false) + checkShadowing(false) + if (refusal) return { stop: refusal } + const stop = terminated.get(turn.run_id) + if (stop) return { stop } + if (handlers('before_agent_start').length === 0) return + let note = notes.get(turn.run_id) + if (!note) { + note = startNote(turn.latest_user_text ?? '', turn.signal) + notes.set(turn.run_id, note) + } + const added = await note + return added ? { note: added } : undefined + }, + + async finish(outcome) { + notes.delete(outcome.run_id) + terminated.delete(outcome.run_id) + for (const key of judged.keys()) if (key.startsWith(`${outcome.run_id}/`)) judged.delete(key) + }, + }), + ) + }, +}) diff --git a/integrations/yoagent-rutis/tests/pi_log_test.rs b/integrations/yoagent-rutis/tests/pi_log_test.rs new file mode 100644 index 0000000..3a3f946 --- /dev/null +++ b/integrations/yoagent-rutis/tests/pi_log_test.rs @@ -0,0 +1,124 @@ +//! The pi adapter's diagnostics reach the host's `tracing` output +//! through the bridge's `log` method (target `yoagent_rutis::plugin`), not +//! only the plugin process's stderr: the pi adapter reports what it ignores. +//! +//! Its own test binary with a single test: it installs a global subscriber +//! (the bridge logs from Tokio's worker threads). Needs Node 24+ and `npm ci` +//! in `plugins/pi/`; without them it prints `SKIPPED:` and passes, or fails +//! with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use rutis::Ctx; +use rutis_bridge::runtime::LocalRuntime; +use rutis_loader::{ + Chain, Layer, LoaderOptions, LoaderPlugin, Patch, RuntimeResolver, RuntimeRowsPlugin, + ServiceCatalog, +}; +use serde_json::json; +use tracing_subscriber::layer::SubscriberExt; +use yoagent_rutis::RutisBridge; + +/// Every event as `target level message`. +#[derive(Clone, Default)] +struct CapturedLogs(Arc>>); + +impl tracing_subscriber::Layer for CapturedLogs { + fn on_event(&self, event: &tracing::Event<'_>, _: tracing_subscriber::layer::Context<'_, S>) { + struct Message(String); + impl tracing::field::Visit for Message { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.0 = format!("{value:?}"); + } + } + } + let mut message = Message(String::new()); + event.record(&mut message); + let meta = event.metadata(); + self.0 + .lock() + .unwrap() + .push(format!("{} {} {}", meta.target(), meta.level(), message.0)); + } +} + +fn pi_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins/pi") +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_plugin_warning_reaches_the_hosts_logs() { + let runtime = pi_dir().join("node_modules/@arcships/rutis-runtime"); + if !runtime.join("package.json").exists() { + if std::env::var_os("YOAGENT_RUTIS_REQUIRE_RUNTIMES").is_some_and(|v| v == "1") { + panic!("the pi runtime is required but unavailable: run `npm ci` in plugins/pi/"); + } + eprintln!("SKIPPED: run `npm ci` in plugins/pi/"); + return; + } + let logs = CapturedLogs::default(); + tracing::subscriber::set_global_default(tracing_subscriber::registry().with(logs.clone())) + .unwrap(); + + let root = Ctx::root().unwrap(); + let bridge = RutisBridge::install(&root).unwrap(); + let mut catalog = ServiceCatalog::new(); + catalog.register_shared("yoagent"); + let node = LocalRuntime::node(&runtime, pi_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)); + // The fixture registers a command: the adapter reports it as not available. + let patches: Vec = serde_json::from_value(json!([{ "insert": [{ + "id": "pi", + "name": pi_dir().join("pi-extensions-adapter.ts"), + "config": { "extensions": [pi_dir().join("fixture-extension.ts")] }, + }] }])) + .unwrap(); + let report = loader + .reconcile(vec![Layer::new("rows", patches)], None) + .await + .unwrap(); + assert!(report.failures.is_empty(), "{report:?}"); + + let found = tokio::time::timeout(Duration::from_secs(60), async { + loop { + let hit = logs + .0 + .lock() + .unwrap() + .iter() + .find(|l| { + l.starts_with("yoagent_rutis::plugin WARN") && l.contains("command /fixture") + }) + .cloned(); + if let Some(line) = hit { + return line; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "no plugin warning in the host's logs: {:?}", + logs.0.lock().unwrap() + ) + }); + assert!(found.contains("not available in yoagent"), "{found}"); + let _ = bridge; + root.shutdown().await.unwrap(); +} diff --git a/integrations/yoagent-rutis/tests/pi_test.rs b/integrations/yoagent-rutis/tests/pi_test.rs new file mode 100644 index 0000000..8218faa --- /dev/null +++ b/integrations/yoagent-rutis/tests/pi_test.rs @@ -0,0 +1,932 @@ +//! pi coding-agent extensions in yoagent, end to end: the adapter +//! `plugins/pi/pi-extensions-adapter.ts` loading pi extensions — the fixtures +//! `plugins/pi/fixture-extension.ts` and `plugins/pi/fixture-extra.ts`, and +//! small ones written to a temporary directory; no network — as a +//! rutis-loader row of a Node runtime. +//! +//! Needs Node 24+ and `npm ci` in `plugins/pi/`. 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, Mutex}; +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::tools::{ + BashTool, EditFileTool, ListFilesTool, ReadFileTool, SearchTool, WriteFileTool, +}; +use yoagent::{AgentEvent, AgentMessage, Content, Message, ToolDecision}; +use yoagent_rutis::RutisBridge; + +fn pi_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins/pi") +} + +/// The Node runtime package, if Node 24+ and `npm ci` in `plugins/pi/` 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 pi runtime is required (YOAGENT_RUTIS_REQUIRE_RUNTIMES=1) but unavailable: {why}"); + } + eprintln!("SKIPPED: the pi 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 = pi_dir().join("node_modules"); + for package in ["@arcships/rutis-runtime", "@earendil-works/pi-coding-agent"] { + if !modules.join(package).join("package.json").exists() { + return skip(format!( + "{package} missing from {}: run `npm ci` in plugins/pi/", + 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, pi_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 { + /// Load the rows; the loader's failures, as text (empty when none). + async fn try_load(&self, rows: Value) -> String { + let patches: Vec = serde_json::from_value(json!([{ "insert": rows }])).unwrap(); + let report = self + .loader + .reconcile(vec![Layer::new("rows", patches)], None) + .await + .unwrap(); + if report.failures.is_empty() { + String::new() + } else { + format!("{:?}", report.failures) + } + } + + async fn load(&self, rows: Value) { + let failures = self.try_load(rows).await; + assert!(failures.is_empty(), "{failures}"); + let registry = self.bridge.registry().clone(); + tokio::time::timeout(Duration::from_secs(60), async { + while !registry + .handlers() + .iter() + .any(|h| h.name() == "pi-extensions") + { + 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 pi_extensions_reach_a_yoagent_agent_through_the_adapter() { + let Some(runtime) = node_runtime() else { + return; + }; + let project = tempfile::tempdir().unwrap(); + let env_file = project.path().join(".env"); + let notes = project.path().join("notes.txt"); + std::fs::write(¬es, "hello world").unwrap(); + let multi = project.path().join("multi.txt"); + std::fs::write(&multi, "one").unwrap(); + + let host = host(&runtime).await; + host.load(json!([{ + "id": "pi", + "name": pi_dir().join("pi-extensions-adapter.ts"), + "config": { + "extensions": [pi_dir().join("fixture-extension.ts")], + "cwd": project.path(), + }, + }])) + .await; + + let (agent, seen) = agent(vec![ + calls(&[ + ("pi_echo", json!({"text": "SECRET hi"})), + ("pi_fail", json!({"why": "nope"})), + ("pi_dynamic", json!({})), + ("pi_picture", json!({})), + ( + "write_file", + json!({"path": env_file, "content": "TOKEN=1"}), + ), + ("bash", json!({"command": "echo original"})), + ( + "edit_file", + json!({"path": notes, "old_text": "world", "new_text": "pi"}), + ), + // The policy adds a second edit, which yoagent's edit_file cannot run. + ( + "edit_file", + json!({"path": multi, "old_text": "one", "new_text": "two"}), + ), + ]), + text("done"), + call("pi_slow", json!({})), + text("never sent"), + ]); + let mut agent = agent + .with_tools(vec![ + Box::new(BashTool::new()), + Box::new(WriteFileTool::new()), + Box::new(EditFileTool::new()), + ]) + .with_extension(host.bridge.extension()); + let (_, results) = tokio::time::timeout(Duration::from_secs(60), run(&mut agent, "go")) + .await + .expect("the run finishes"); + + // The extension's tools are offered, the one registered at session start too... + let seen_now = seen.lock().unwrap().clone(); + for tool in ["pi_echo", "pi_fail", "pi_slow", "pi_dynamic"] { + assert!( + seen_now[0].tools.contains(&tool.to_string()), + "{seen_now:?}" + ); + } + let result = |name: &str| { + results + .iter() + .find(|(n, _, _)| n == name) + .unwrap_or_else(|| panic!("no {name} result: {results:?}")) + .clone() + }; + // ...a tool's text comes back, through the extension's tool_result redaction... + assert_eq!( + result("pi_echo"), + ("pi_echo".into(), "pi echo: [redacted] hi".into(), false) + ); + // ...a throwing tool is an error result... + let (_, text, is_error) = result("pi_fail"); + assert!(is_error && text.contains("pi failure: nope"), "{results:?}"); + assert_eq!(result("pi_dynamic").1, "dynamic ok"); + // ...a picture arrives as an image, and a content edit keeps it... + let picture = agent + .messages() + .iter() + .find_map(|m| match m { + AgentMessage::Llm(Message::ToolResult { + tool_name, content, .. + }) if tool_name == "pi_picture" => Some(content.clone()), + _ => None, + }) + .unwrap(); + assert!( + matches!(&picture[..], [Content::Text { text: a }, Content::Image { mime_type, .. }, Content::Text { text: b }] + if a == "a dot" && mime_type == "image/png" && b == "captioned"), + "{picture:?}" + ); + // ...yoagent's own write_file is judged as pi's `write` and blocked... + let (_, text, is_error) = result("write_file"); + assert!(is_error && text.contains("is protected"), "{results:?}"); + assert!(!env_file.exists(), "the blocked write never ran"); + // ...an in-place rewrite of `event.input` changes the call... + let (_, text, is_error) = result("bash"); + assert!(!is_error && text.contains("rewritten"), "{results:?}"); + // ...and a rewrite yoagent's tool cannot run is denied, not cut down. + let (_, text, is_error) = results + .iter() + .filter(|(n, _, _)| n == "edit_file") + .nth(1) + .cloned() + .unwrap(); + assert!(is_error && text.contains("one edit"), "{results:?}"); + assert_eq!(std::fs::read_to_string(&multi).unwrap(), "one"); + // ...including edit_file's arguments, translated to pi's `edits` and back. + assert!(!result("edit_file").2, "{results:?}"); + assert_eq!(std::fs::read_to_string(¬es).unwrap(), "hello PI"); + // before_agent_start's addition to the system prompt arrives as a note, never stored. + let note = &seen_now[0].last_user; + assert!( + note.contains("Fixture rules: answer in one line."), + "{note}" + ); + assert!( + !format!("{:?}", agent.messages()).contains("Fixture rules"), + "the note is never stored" + ); + + // Cancelling the run aborts the pi tool through its signal. + let mut rx = agent.prompt("slow").await; + let slow = project.path().join("slow.txt"); + wait_file(&slow, |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; + // Not empty: writeFileSync truncates before it writes. + let ended = wait_file(&slow, |t| t != "started" && !t.is_empty()).await; + assert_eq!(ended, "aborted: AbortError"); + host.root.shutdown().await.unwrap(); + // session_shutdown ran when the adapter unloaded. + wait_file(&project.path().join("shutdown.txt"), |t| t == "bye").await; +} + +/// A 1×1 PNG. +const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, +]; + +#[tokio::test(flavor = "multi_thread")] +async fn pi_semantics_on_the_less_common_paths() { + let Some(runtime) = node_runtime() else { + return; + }; + let project = tempfile::tempdir().unwrap(); + let notes = project.path().join("notes.txt"); + std::fs::write(¬es, "hello world").unwrap(); + let image = project.path().join("dot.png"); + std::fs::write(&image, PNG).unwrap(); + std::fs::write(project.path().join("a.md"), "needle in markdown").unwrap(); + // Upper-case: found only because yoagent's search stays case-insensitive. + std::fs::write(project.path().join("b.txt"), "NEEDLE in text").unwrap(); + std::fs::create_dir(project.path().join("inner")).unwrap(); + std::fs::write(project.path().join("inner/deep.txt"), "deep").unwrap(); + + let host = host(&runtime).await; + host.load(json!([{ + "id": "pi", + "name": pi_dir().join("pi-extensions-adapter.ts"), + "config": { + "extensions": [pi_dir().join("fixture-extension.ts"), pi_dir().join("fixture-extra.ts")], + "cwd": project.path(), + }, + }])) + .await; + + let (agent, seen) = agent(vec![ + calls(&[ + ("pi_echo", json!({"text": "first"})), + ("pi_echo", json!({"text": "boom"})), + ("pi_soft_error", json!({})), + ("pi_inactive", json!({})), + ( + "edit_file", + json!({"path": notes, "old_text": "world", "new_text": "pi"}), + ), + ( + "edit", + json!({"path": "x.txt", "edits": "[{\"oldText\":\"a\",\"newText\":\"b\"}]"}), + ), + // Already upper-case: the policy changes nothing, only the preparation does. + ( + "edit", + json!({"path": "y.txt", "edits": "[{\"oldText\":\"c\",\"newText\":\"D\"}]"}), + ), + ("read_file", json!({"path": image})), + ( + "search", + json!({"pattern": "needle", "path": project.path(), "include": "*.md"}), + ), + // Fails pi's validation: `text` is required. + ("pi_echo", json!({})), + ("list_files", json!({"path": project.path()})), + ("bash", json!({"command": "echo SECRET"})), + ("bash", json!({"command": "echo BREAK"})), + ("bash", json!({"command": "echo bounded"})), + // Relative: resolved against the extensions' cwd, not the test's. + ("write_file", json!({"path": "rel.txt", "content": "here"})), + // Nested calls from a pi tool. + ("pi_compose", json!({})), + // Session calls inside a tool: recorded, not failing the tool. + ("pi_render", json!({})), + // Blocked with terminate: the run stops before its next request. + ("bash", json!({"command": "echo stop-now"})), + ]), + text("never requested"), + ]); + let mut agent = agent + .with_tools(vec![ + Box::new(ReadFileTool::new()), + Box::new(EditFileTool::new()), + Box::new(SearchTool::new()), + Box::new(ListFilesTool::new()), + Box::new(BashTool::new()), + Box::new(WriteFileTool::new()), + ]) + .with_extension(host.bridge.extension()); + let (events, results) = tokio::time::timeout(Duration::from_secs(60), run(&mut agent, "go")) + .await + .expect("the run finishes"); + let seen_now = seen.lock().unwrap().clone(); + let result = |i: usize| results[i].clone(); + + // The first registration of a name wins, as in pi. + assert_eq!(result(0).1, "pi echo: first", "{results:?}"); + // A throwing tool_call handler blocks the call, as in pi. + assert!( + result(1).2 && result(1).1.contains("policy crashed"), + "{results:?}" + ); + // A returned isError is an error result. + assert!( + result(2).2 && result(2).1.contains("soft failure"), + "{results:?}" + ); + // defaultActive: false is not offered, so the call fails as unknown. + assert!(!seen_now[0].tools.contains(&"pi_inactive".to_string())); + assert!( + result(3).2 && result(3).1.contains("Tool pi_inactive not found"), + "{results:?}" + ); + // yoagent's edit_file is denied: an extension overrides pi's `edit`. + assert!( + result(4).2 && result(4).1.contains(r#"call "edit" instead"#), + "{results:?}" + ); + assert_eq!(std::fs::read_to_string(¬es).unwrap(), "hello world"); + // The pi `edit` tool: prepared in place (edits parsed from a string, as + // pi's own edit does) before the policy (which upper-cases newText), and + // never translated as edit_file. + let (_, text, is_error) = result(5); + assert!( + !is_error && text.contains(r#""edits":[{"oldText":"a","newText":"B"}]"#), + "{results:?}" + ); + // Prepared arguments reach the tool even when no policy changes them. + let (_, text, is_error) = result(6); + assert!( + !is_error && text.contains(r#""edits":[{"oldText":"c","newText":"D"}]"#), + "{results:?}" + ); + // A details-only tool_result edit keeps the image. + let image_kept = agent.messages().iter().any(|m| { + matches!(m, AgentMessage::Llm(Message::ToolResult { tool_name, content, .. }) + if tool_name == "read_file" && content.iter().any(|c| matches!(c, Content::Image { .. }))) + }); + assert!(image_kept, "{:?}", agent.messages()); + let details = events.iter().find_map(|e| match e { + AgentEvent::ToolExecutionEnd { + tool_name, result, .. + } if tool_name == "read_file" => Some(result.details.clone()), + _ => None, + }); + assert_eq!( + details, + Some(json!({"seen": true})), + "the details edit landed" + ); + // search's include is grep's glob, and its default case-insensitivity is + // ignoreCase: true both ways: the rewrite to *.txt reached the tool, which + // still matched the upper-case NEEDLE. + let (_, text, _) = result(8); + assert!( + text.contains("b.txt") && !text.contains("a.md"), + "{results:?}" + ); + // A pi tool's arguments are validated before the policies: a missing field denies. + assert!(result(9).2 && result(9).1.contains("text"), "{results:?}"); + // list_files as pi's find: the required pattern defaulted to '*', and the + // policy's path rewrite reached the tool. + let (_, text, is_error) = result(10); + assert!( + !is_error && text.contains("deep.txt") && !text.contains("a.md"), + "{results:?}" + ); + // A content-replacing tool_result edit applies to yoagent's built-ins too. + let (_, text, _) = result(11); + assert!( + text.contains("[redacted]") && !text.contains("SECRET"), + "{results:?}" + ); + // A tool_result handler that throws withholds the result. + let (_, text, _) = result(12); + assert!( + text.contains("withheld") && !text.contains("BREAK"), + "{results:?}" + ); + // A rewrite to a field yoagent's bash does not have is denied. + assert!( + result(13).2 && result(13).1.contains(r#""timeout""#), + "{results:?}" + ); + // The relative path was resolved where the policies looked. + assert!(!result(14).2, "{results:?}"); + assert_eq!( + std::fs::read_to_string(project.path().join("rel.txt")).unwrap(), + "here" + ); + // ctx.executeTool: a nested pi tool runs (its result redacted, its id under + // the parent's), a policy can block one, and yoagent's tools are out of reach. + let (_, text, is_error) = result(15); + assert!(!is_error, "{results:?}"); + assert!(text.contains("callable: true"), "{text}"); + assert!( + text.contains("/1 pi_echo ok: pi echo: nested [redacted]"), + "{text}" + ); + assert!( + text.contains("/2 pi_echo error:") && text.contains("policy crashed"), + "{text}" + ); + assert!( + text.contains("/3 bash error: Tool bash not found"), + "{text}" + ); + // A nested tool's throw goes through tool_result too (a handler scoped to + // pi_fail rewrote it). + assert!( + text.contains("/4 pi_fail error: pi_fail error seen"), + "{text}" + ); + // Called with no arguments: an empty object. + assert!(text.contains("/5 pi_dynamic ok: dynamic ok"), "{text}"); + // appendEntry and sendMessage inside a tool are recorded in the adapter's session. + let (_, text, is_error) = result(16); + assert!( + !is_error && text == "rendered; entries 1, messages 1", + "{results:?}" + ); + // terminate: true denied the call and stopped the run before its next request. + assert!( + result(17).2 && result(17).1.contains("stopping the run"), + "{results:?}" + ); + assert_eq!(seen_now.len(), 1, "the run stopped: {seen_now:?}"); + // The crashing and prompt-replacing before_agent_start handlers are + // skipped; the message-returning one loses only its message; a handler + // after them still counts. + let note = &seen_now[0].last_user; + for kept in [ + "Fixture rules: answer in one line.", + "Message-handler rules: kept.", + "Extra rules: last.", + ] { + assert!(note.contains(kept), "{kept} missing: {note}"); + } + assert!(!note.contains("a whole new prompt"), "{note}"); + host.root.shutdown().await.unwrap(); +} + +/// Writes a pi extension to `dir` and returns its path. +fn extension(dir: &Path, name: &str, source: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, source).unwrap(); + path +} + +fn adapter_row(config: Value) -> Value { + json!([{ + "id": "pi", + "name": pi_dir().join("pi-extensions-adapter.ts"), + "config": config, + }]) +} + +#[tokio::test(flavor = "multi_thread")] +async fn strict_refuses_extensions_that_use_what_does_not_map() { + let Some(runtime) = node_runtime() else { + return; + }; + let host = host(&runtime).await; + let failures = host + .try_load(adapter_row(json!({ + "extensions": [pi_dir().join("fixture-extension.ts")], + "strict": true, + }))) + .await; + assert!( + failures.contains("command /fixture"), + "the fixture's command fails a strict load: {failures}" + ); + assert!(host.bridge.registry().handlers().is_empty()); + host.root.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_pi_tool_named_like_a_builtin_needs_the_host_to_leave_it_out() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let ext = extension( + dir.path(), + "sandboxed-bash.ts", + r#"import { Type } from 'typebox' +export default function (pi) { + pi.registerTool({ + name: 'bash', label: 'bash (sandboxed)', description: 'Run a command in a sandbox.', + parameters: Type.Object({ command: Type.String() }), + async execute() { return { content: [{ type: 'text', text: 'sandboxed' }], details: undefined } }, + }) +} +"#, + ); + // Without withoutBuiltins, the load is refused (no strict needed). + let host = host(&runtime).await; + let failures = host + .try_load(adapter_row(json!({ "extensions": [ext] }))) + .await; + assert!( + failures.contains("withoutBuiltins") && failures.contains("bash"), + "{failures}" + ); + assert!(host.bridge.registry().handlers().is_empty()); + host.root.shutdown().await.unwrap(); + + // With it, the pi tool is the agent's bash. + let host = self::host(&runtime).await; + host.load(adapter_row( + json!({ "extensions": [ext], "withoutBuiltins": ["bash"] }), + )) + .await; + let (agent, _) = agent(vec![call("bash", json!({"command": "ls"})), text("done")]); + let mut agent = agent.with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "go").await; + assert_eq!( + results, + vec![("bash".into(), "sandboxed".into(), false)], + "{results:?}" + ); + host.root.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn what_could_leave_a_policy_unenforced_refuses_the_load() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let cases = [ + ( + "throws.ts", + "export default function () { throw new Error('factory crashed') }\n", + "factory crashed", + ), + ( + "unsupported-start.ts", + "export default function (pi) { pi.on('session_start', () => pi.sendUserMessage('x')) }\n", + "session_start", + ), + ( + "context.ts", + "export default function (pi) { pi.on('context', () => ({ messages: [] })) }\n", + "allowUnmapped", + ), + ]; + for (file, source, want) in cases { + let ext = extension(dir.path(), file, source); + let host = host(&runtime).await; + // Loaded next to a working extension: nothing loads, not just the bad one. + let failures = host + .try_load(adapter_row(json!({ + "extensions": [pi_dir().join("fixture-extension.ts"), ext], + }))) + .await; + assert!(failures.contains(want), "{file}: {failures}"); + assert!(host.bridge.registry().handlers().is_empty(), "{file}"); + host.root.shutdown().await.unwrap(); + } + + // A deciding event the host accepts going unenforced. + let ext = dir.path().join("context.ts"); + let host = host(&runtime).await; + host.load(adapter_row( + json!({ "extensions": [ext], "allowUnmapped": ["context"] }), + )) + .await; + host.root.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn set_active_tools_narrows_what_the_agent_can_call() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let readme = dir.path().join("README.md"); + std::fs::write(&readme, "readme").unwrap(); + let ext = extension( + dir.path(), + "plan-mode.ts", + r#"import { Type } from 'typebox' +const tool = (name, extra = {}) => ({ + name, label: name, description: name, parameters: Type.Object({}), + async execute() { return { content: [{ type: 'text', text: `${name} ran` }], details: undefined } }, + ...extra, +}) +export default function (pi) { + pi.registerTool(tool('pi_x', { + // Registers a tool after setActiveTools: pi's registry refresh activates it. + async execute() { + pi.registerTool(tool('pi_late')) + // pi's usual narrowing: it must keep the tool just registered. + pi.setActiveTools(pi.getActiveTools().filter((n) => n !== 'bash')) + return { content: [{ type: 'text', text: 'pi_x ran' }], details: undefined } + }, + })) + pi.registerTool(tool('pi_y')) + // Not activated on registration, but named in the allowlist: pi activates it. + pi.registerTool(tool('pi_z', { defaultActive: false })) + pi.on('session_start', () => pi.setActiveTools(['read', 'pi_x', 'pi_z'])) + // A policy, so built-in paths are resolved — but not other tools' paths. + pi.on('tool_call', () => undefined) +} +"#, + ); + let host = host(&runtime).await; + host.load(adapter_row(json!({ "extensions": [ext] }))).await; + let (agent, seen) = agent(vec![ + calls(&[ + ("pi_x", json!({})), + ("pi_y", json!({})), + ("bash", json!({"command": "echo hi"})), + ("read_file", json!({"path": readme})), + // A tool pi does not know (an MCP tool, say): untouched by the + // allowlist and by the path resolution. + ("echo_args", json!({"path": "src/lib.rs"})), + ]), + text("done"), + text("again"), + ]); + let mut agent = agent + .with_tools(vec![ + Box::new(BashTool::new()), + Box::new(ReadFileTool::new()), + Box::new(EchoArgs), + ]) + .with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "go").await; + let tools = seen.lock().unwrap()[0].tools.clone(); + assert!( + tools.contains(&"pi_x".into()) + && tools.contains(&"pi_z".into()) + && !tools.contains(&"pi_y".into()), + "{tools:?}" + ); + assert_eq!( + results[4], + ("echo_args".into(), r#"{"path":"src/lib.rs"}"#.into(), false), + "{results:?}" + ); + // The tool pi_x registered joins the allowlist from the next run on. + run(&mut agent, "again").await; + let tools = seen.lock().unwrap()[2].tools.clone(); + assert!(tools.contains(&"pi_late".into()), "{tools:?}"); + assert_eq!(results[0].1, "pi_x ran", "{results:?}"); + // Not offered, and a call to it is denied as inactive. + assert!( + results[1].2 && results[1].1.contains(r#""pi_y" is not an active tool"#), + "{results:?}" + ); + // yoagent's bash is pi's `bash`, which the extension left out... + assert!( + results[2].2 && results[2].1.contains("not an active tool"), + "{results:?}" + ); + // ...while read_file is pi's `read`, which it kept. + assert!( + !results[3].2 && results[3].1.contains("readme"), + "{results:?}" + ); + host.root.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn prompt_notes_are_per_run_and_input_handlers_reject() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let ext = extension( + dir.path(), + "per-run.ts", + r#"import { Type } from 'typebox' +let calls = 0 +export default function (pi) { + pi.registerTool({ + name: 'pi_ping', label: 'ping', description: 'ping', parameters: Type.Object({}), + async execute() { return { content: [{ type: 'text', text: 'pong' }], details: undefined } }, + }) + pi.on('before_agent_start', (event) => { + calls += 1 + return { systemPrompt: `${event.systemPrompt}\nPrompt: ${event.prompt} (call ${calls})` } + }) + pi.on('input', (event) => { + if (event.text.includes('SECRET')) return { action: 'handled' } + if (event.text.includes('rewrite')) return { action: 'transform', text: 'x' } + return { action: 'continue' } + }) +} +"#, + ); + let host = host(&runtime).await; + host.load(adapter_row(json!({ "extensions": [ext] }))).await; + let (agent, seen) = agent(vec![call("pi_ping", json!({})), text("one"), text("two")]); + let mut agent = agent.with_extension(host.bridge.extension()); + run(&mut agent, "first").await; + run(&mut agent, "second").await; + let seen_now = seen.lock().unwrap().clone(); + assert_eq!(seen_now.len(), 3, "{seen_now:?}"); + // Both requests of the first run carry its note; the handler ran once per run. + for request in &seen_now[..2] { + assert!( + request.last_user.contains("Prompt: first (call 1)"), + "{seen_now:?}" + ); + } + assert!( + seen_now[2].last_user.contains("Prompt: second (call 2)"), + "{seen_now:?}" + ); + assert!( + seen_now.iter().all(|r| !r.last_user.contains('\u{0}')), + "the prompt placeholder never leaks" + ); + + // `handled` and `transform` both reject the prompt before any request. + for prompt in ["my SECRET plan", "please rewrite this"] { + let (events, _) = run(&mut agent, prompt).await; + assert!( + events + .iter() + .any(|e| matches!(e, AgentEvent::InputRejected { .. })), + "{prompt}: {events:?}" + ); + } + assert_eq!(seen.lock().unwrap().len(), 3, "no request was sent"); + host.root.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_pi_tool_runs_only_the_arguments_its_policies_judged() { + let Some(runtime) = node_runtime() else { + return; + }; + let host = host(&runtime).await; + host.load(adapter_row( + json!({ "extensions": [pi_dir().join("fixture-extension.ts")] }), + )) + .await; + // A handler registered after the adapter rewrites pi_echo's arguments. + let rewrites = Arc::new(Mutex::new(0)); + let counter = rewrites.clone(); + let later = host + .root + .plugin(plugin(handler("later").with_before_tool(move |call| { + if call.tool == "pi_echo" { + *counter.lock().unwrap() += 1; + ToolDecision::Modify(json!({"text": "tampered"})) + } else { + ToolDecision::Allow + } + }))); + wait_active(&later).await; + let (agent, _) = agent(vec![call("pi_echo", json!({"text": "hi"})), text("done")]); + let mut agent = agent.with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "go").await; + assert_eq!(*rewrites.lock().unwrap(), 1); + assert!( + results[0].2 + && results[0] + .1 + .contains("changed after pi's policies judged them"), + "{results:?}" + ); + host.root.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_deciding_event_registered_mid_run_stops_that_run() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let ext = extension( + dir.path(), + "late-context.ts", + r#"import { Type } from 'typebox' +export default function (pi) { + pi.registerTool({ + name: 'pi_arm', label: 'arm', description: 'registers a context handler', parameters: Type.Object({}), + async execute() { + pi.on('context', () => ({ messages: [] })) + return { content: [{ type: 'text', text: 'armed' }], details: undefined } + }, + }) +} +"#, + ); + let host = host(&runtime).await; + host.load(adapter_row(json!({ "extensions": [ext] }))).await; + let (agent, seen) = agent(vec![ + call("pi_arm", json!({})), + call("pi_arm", json!({})), + text("never requested"), + ]); + let mut agent = agent.with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "go").await; + // The handler appeared during the run: its next model request is stopped. + assert_eq!(results.len(), 1, "{results:?}"); + assert_eq!( + seen.lock().unwrap().len(), + 1, + "the second request never went out" + ); + let last = format!("{:?}", agent.messages().last()); + assert!(last.contains("context"), "{last}"); + host.root.shutdown().await.unwrap(); +}