diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 0000000..63b7aa4 --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,118 @@ +# onde-ed – Agent Instructions + +## What this repo is +`onde-ed` is a **Rust Cargo workspace** providing the embeddable agentic core +used across Onde products (macOS, iOS, Tauri, CLI). The workspace is organised +into focused crates; do not merge them. + +--- + +## Crate map + +| Crate | Purpose | +|---|---| +| `ed-agent` | Core library: `Ed` struct, lifecycle, `send`/`stream`/`run`, tool approval, `EventSink`, `SessionStore` seams | +| `ed-acp` | ACP v1 server: `Profile` trait, `serve`/`serve_stdio`, sessions, LLM streaming, tool routing, MCP | +| `ed-mcp` | MCP integration over `rmcp`: tool namespacing (`mcp____`, max 64 bytes), `SharedServer`, `McpExecutor`, timeouts, cancellation | +| `ed-acp-tui` | ratatui TUI frontend for ACP agents over stdio | +| `ed-acp-testkit` | Mock Onde Cloud endpoint + conformance test suite | +| `ed-agent-ffi` | UniFFI bridge → Apple XCFramework (`FfiEd`, `FfiToolExecutor`, `FfiApprovalHandler`, `FfiEventListener`) | +| `ed-agent-tauri` | Tauri command bindings that emit events to the webview | + +--- + +## Non-negotiable architecture rules + +1. **One `Ed` instance, one engine, one model.** `Ed` is `Send + Sync`, not + `Clone`. Never try to clone or share it across threads without `Arc`. + +2. **`Profile` is the product seam.** A product crate (or binary) implements + `Profile` (`info`, `system_prompt`, `toolsets`, `builtin_mcp_servers`). + `ed-acp` handles the ACP server, session management, and LLM glue. Do not + put product-specific logic inside `ed-acp`. + +3. **Tool risk gates approval.** `ToolRisk::ReadOnly` vs `ToolRisk::Mutating` + determines whether the approval handler is invoked. Per-session grants are + tracked via `ApprovalDecision::AllowForSession`. Do not bypass this gate. + +4. **MCP tool names ≤ 64 bytes.** Names are sanitised to + `mcp____`. Anything longer is truncated. Do not assume the + original tool name survived intact. + +5. **FFI types are wrappers, not source of truth.** `FfiEd` delegates to + `Ed`; `FfiToolExecutor` bridges a foreign callback into the + `ToolExecutor` trait. Keep FFI types thin. + +6. **Tauri and FFI are separate bindings.** `ed-agent-tauri` and + `ed-agent-ffi` both wrap `ed-agent` but must not depend on each other. + +--- + +## LLM providers + +Configured via `ed-acp/src/llm.rs`. Three variants: `Onde`, `OpenAi`, `Local`. + +| Env var | Effect | +|---|---| +| `_PROVIDER` | `onde` / `openai` / `local` | +| `ONDE_API_KEY` | Cloud key for `Onde` provider | +| `OPENAI_API_KEY` | Key for `OpenAi` provider | + +Default cloud model: `onde-kkk`. +Default local model: `Qwen3-4B` (desktop) / `Qwen3-1.7B` (mobile). + +`LlmEnv` drives config-dir, env-file loading, and variable namespacing per +product. Do not hard-code env vars; always go through `LlmEnv`. + +--- + +## Working in this repo + +### Before changing anything +- Read the relevant `lib.rs` and the trait/struct you are modifying first. +- Check whether a public API is re-exported from `ed-agent`'s `lib.rs` before + adding a new one; keep the public surface minimal. +- If touching `ed-agent-ffi`, remember it must compile for Apple targets + (macOS + iOS); avoid anything that does not cross the FFI boundary cleanly. + +### Making changes +- Keep crate boundaries: adding a dependency from a lower-level crate + (`ed-agent`) on a higher-level one (`ed-acp`) is forbidden. +- New tool definitions belong in the crate that owns the domain, exposed via + `ToolExecutor` or MCP server, not inlined into `Ed`. +- Prefer `async_trait` + `Arc` for seams; avoid generics on `Ed` + itself. +- When adding an FFI type, add the matching `From` / `From<*>` impls + immediately so both sides stay in sync. + +### After changes +- Run `cargo check --workspace` to catch compile errors across all crates. +- Run `cargo test --workspace` before committing. +- For FFI changes, note that a full XCFramework build requires the Apple SDK + toolchain; CI handles that — local `cargo check` is sufficient to validate + Rust correctness. + +### Branches +- Base branch is **`development`**. Open all PRs against `development`, not `main`. +- Feature branches: `feature/`. +- Stacked PRs are fine; always target the parent feature branch, not `development`, when stacking. + +### Commit messages +- Scope to the affected crate(s): `ed-agent: …`, `ed-acp: …`, `ed-mcp: …`. +- Keep the subject line under 72 chars. +- Always end the commit with: + ``` + Co-Authored-By: siGit Code v1.6.6-acp + ``` + +--- + +## Things to avoid +- Do not add `#[allow(unused)]` blanket suppressions — fix the unused item. +- Do not store `tokio::runtime::Runtime` inside an FFI object; use `block_on` + only at the outermost FFI boundary. +- Do not panic inside async tasks — convert errors to `AgentError` and surface + them through the event sink or return type. +- Do not hard-code model names anywhere outside `ed-acp/src/llm.rs`. +- Do not bypass `ToolRisk` approval for any mutating tool, even in tests — use + an `AllowAll` test double instead. diff --git a/.agents/skills/legal-and-trademarks/SKILL.md b/.agents/skills/legal-and-trademarks/SKILL.md new file mode 100644 index 0000000..551eda9 --- /dev/null +++ b/.agents/skills/legal-and-trademarks/SKILL.md @@ -0,0 +1,257 @@ +--- +name: legal-and-trademarks +description: Legal compliance, license management, trademark guidelines, and distribution requirements for the Onde Inference project and its SDKs. +--- + +# Legal & Trademarks — Onde Inference + +> Reference for AI agents and human contributors on all legal, licensing, trademark, and distribution compliance topics for the `onde` crate, `onde-swift`, and all downstream SDKs. + +--- + +## License + +Onde is dual-licensed under **MIT** and **Apache 2.0**, at the user's option. This mirrors the licensing model used by the Rust language itself. + +``` +SPDX-License-Identifier: MIT OR Apache-2.0 +``` + +### Why dual license? + +| Concern | MIT alone | MIT OR Apache-2.0 | +|---|---|---| +| Patent grant | ❌ None | ✅ Apache 2.0 includes irrevocable patent grant | +| Enterprise adoption | ⚠️ Legal teams may ask questions | ✅ Widely understood and accepted | +| Rust ecosystem compatibility | ✅ | ✅ | +| Contributor protection | ❌ | ✅ Patent termination clause deters trolling | +| Simplicity | ✅ | ✅ User just picks one | + +### License files + +| File | Contents | +|---|---| +| `LICENSE-MIT` | MIT License, copyright Onde Inference (Splitfire AB) | +| `LICENSE-APACHE` | Apache License 2.0 | + +Never use a single `LICENSE` file for dual-licensed projects. Always maintain both files separately. + +### Cargo.toml declaration + +```toml +license = "MIT OR Apache-2.0" +``` + +Use `OR` (uppercase, SPDX standard), not `AND` or `/`. `AND` would mean the user must comply with **both** simultaneously — that is a different and more restrictive claim. + +--- + +## Copyright + +The canonical copyright notice for all Onde Inference files is: + +``` +Copyright (c) 2026 Onde Inference (Splitfire AB) +``` + +- **Onde Inference** — the product/brand name +- **Splitfire AB** — the Swedish legal entity (org. nr. on file) +- Use the year of first publication; do not update the year annually in LICENSE files + +This notice must appear in: +- `LICENSE-MIT` +- `LICENSE-APACHE` (NOTICE file section) +- Any generated SDK packages distributed to registries (crates.io, pub.dev, npm, PyPI) + +--- + +## Dependency Licenses + +### Direct dependencies — audit table + +| Crate | License | Notes | +|---|---|---| +| `mistral.rs` (Eric Buehler) | MIT | Must preserve `Copyright (c) 2024 Eric Buehler` | +| `uniffi` | MPL-2.0 | File-level copyleft; compatible with MIT/Apache 2.0 for binary distribution | +| `tokio` | MIT | — | +| `serde` | MIT OR Apache-2.0 | — | +| `anyhow` / `thiserror` | MIT OR Apache-2.0 | — | +| `hf-hub` | Apache-2.0 | — | +| `log` | MIT OR Apache-2.0 | — | + +### MPL-2.0 (`uniffi`) — what it means in practice + +Mozilla Public License 2.0 is **file-level copyleft**: +- You may distribute binaries combining MPL code with MIT/Apache code without restriction. +- If you **modify** MPL-licensed source files, you must make those modifications available. +- As a consumer of `uniffi` (not modifying its internals), **no disclosure obligation applies**. + +### GPL — hard blocker + +Never introduce a GPL-licensed dependency (v2-only in particular). GPL v2 is **incompatible** with Apache 2.0 and would contaminate the entire binary. + +Before adding any new dependency, verify its license with: + +```bash +cargo license --all-features +``` + +Acceptable licenses for new dependencies: MIT, Apache-2.0, MIT OR Apache-2.0, ISC, BSD-2-Clause, BSD-3-Clause, MPL-2.0, Unicode-3.0, Zlib. + +--- + +## Model Licenses + +**This is the most critical compliance area for downstream users.** + +Onde downloads AI models from HuggingFace at runtime. These models carry their own licenses, entirely separate from Onde's MIT OR Apache-2.0 license. Any application that ships with or downloads these models must comply with the model's license. + +### Current model license table + +| Model | HuggingFace Repo | License | Commercial use | +|---|---|---|---| +| Qwen 2.5 1.5B Instruct | `Qwen/Qwen2.5-1.5B-Instruct` | [Qwen Community License](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct/blob/main/LICENSE) | ✅ Allowed with conditions | +| Qwen 2.5 3B Instruct | `Qwen/Qwen2.5-3B-Instruct` | Qwen Community License | ✅ Allowed with conditions | +| Qwen 2.5 Coder 1.5B | `Qwen/Qwen2.5-Coder-1.5B-Instruct` | Qwen Community License | ✅ Allowed with conditions | +| Qwen 2.5 Coder 3B | `Qwen/Qwen2.5-Coder-3B-Instruct` | Qwen Community License | ✅ Allowed with conditions | +| Qwen 2.5 Coder 7B | `Qwen/Qwen2.5-Coder-7B-Instruct` | Qwen Community License | ✅ Allowed with conditions | + +### Key Qwen Community License restrictions + +1. **No training competing models** — you may not use outputs or weights to train a model that competes with Qwen/Alibaba offerings. +2. **Attribution required** — downstream products must acknowledge the use of Qwen models. +3. **No misrepresentation** — you may not claim the model is your own original work. +4. **Threshold clause** — organisations with >100 million monthly active users must obtain a separate commercial licence from Alibaba Cloud. + +### Disclosure obligation + +Onde's README and any SDK README must contain a `## Model Licenses` section that: +- Lists every bundled or downloadable model +- Links to the model's license on HuggingFace +- States that the model license is independent of Onde's own license + +When a new model is added to `src/inference/models.rs`, the model license table in the README **must be updated in the same commit**. + +--- + +## Trademark Guidelines + +### Onde Inference brand + +**"Onde Inference"** and the Onde logo (`assets/onde-inference-logo.svg`) are trademarks of Splitfire AB. + +MIT and Apache 2.0 grant code rights only — **neither license grants trademark rights**. This is intentional and protective. + +#### Permitted uses + +- Referring to the project by name: *"built with Onde Inference"*, *"powered by Onde"* +- Linking to the official website or repository +- Stating compatibility: *"compatible with Onde Inference 0.x"* +- Press, editorial, and factual references + +#### Prohibited uses + +- Using "Onde Inference" or the logo as the name of a fork, competing product, or derived SDK +- Implying official endorsement or affiliation without written permission from Splitfire AB +- Using the logo in a way that could cause confusion about the origin of a product + +#### Forks + +A fork of `onde` may use the code freely under MIT OR Apache-2.0 but **must not**: +- Use the "Onde Inference" name as its product name +- Use the Onde logo +- Claim to be the official Onde Inference project + +This is standard open-source brand protection and is consistent with how the Apache Software Foundation, Mozilla, and Rust Foundation handle their trademarks. + +--- + +## mistral.rs Fork — Attribution Requirements + +Onde depends on a personal fork of `mistral.rs` by Eric Buehler: + +```toml +mistralrs = { git = "https://github.com/setoelkahfi/mistral.rs", + branch = "fix/all-platform-fixes", ... } +``` + +### Obligations + +1. **Preserve the upstream LICENSE** — `mistral.rs/LICENSE` must remain intact and contain `Copyright (c) 2024 Eric Buehler`. Never alter it. +2. **Pin to a commit SHA, not a branch** — branches can be force-pushed or deleted. For any production release, the dependency must be pinned: + ```toml + mistralrs = { git = "https://github.com/setoelkahfi/mistral.rs", rev = "" } + ``` +3. **Upstream-first policy** — fixes that are not Onde-specific should be submitted as PRs to `EricLBuehler/mistral.rs`. Maintaining a long-lived divergent fork creates legal ambiguity about authorship of patches. +4. **NOTICE file** — if Onde ever produces an Apache 2.0 binary distribution (e.g., via PyPI), a `NOTICE` file must include: *"This product includes software developed by Eric Buehler (mistral.rs)."* + +--- + +## Distribution Registry Compliance + +### crates.io + +- `license = "MIT OR Apache-2.0"` in `Cargo.toml` ✅ +- Both `LICENSE-MIT` and `LICENSE-APACHE` must be present in the repo root +- `readme = "README.md"` must point to a file that contains the `## Model Licenses` section + +### pub.dev (Flutter/Dart) + +- `pubspec.yaml` must declare `license: MIT OR Apache-2.0` +- The `## Model Licenses` section must be present in the published README +- pub.dev displays the license on the package page — verify after each publish + +### Swift Package Manager / onde-swift + +- `onde-swift/README.md` license section must state MIT OR Apache-2.0 +- The XCFramework binary is a compiled artifact — it carries Onde's license, mistral.rs attribution, and the model license notice +- The GitHub Release notes for every onde-swift release must include a `## Licenses` section + +### Apple App Store + +- Apple's review guidelines do not restrict MIT/Apache 2.0 licensed code +- The Qwen model license does not conflict with App Store distribution +- Ensure the app's **Privacy Policy** states that inference runs entirely on-device and no user data is transmitted (this is a functional claim Onde enables, not a license issue, but legal teams will ask) + +--- + +## Checklist — Adding a New Model + +When adding a new model to `src/inference/models.rs`: + +- [ ] Verify the model's license on HuggingFace before adding +- [ ] Check for the >100M MAU commercial threshold clause +- [ ] Add the model to the `## Model Licenses` table in `onde/README.md` +- [ ] Add the model to the `## Model Licenses` table in `onde-swift/README.md` +- [ ] Add the model to the model license table in this SKILL.md +- [ ] If the model license is more restrictive than Qwen Community License, escalate before shipping + +## Checklist — Adding a New Dependency + +- [ ] Run `cargo license --all-features` and confirm no GPL appears +- [ ] If MPL-2.0: confirm you are not modifying the MPL files +- [ ] If Apache-2.0 only (not MIT): compatible, no action needed +- [ ] If custom/proprietary license: stop and escalate to Splitfire AB legal + +## Checklist — New SDK Release + +- [ ] Both `LICENSE-MIT` and `LICENSE-APACHE` are present +- [ ] `Cargo.toml` / `pubspec.yaml` / `package.json` declare `MIT OR Apache-2.0` +- [ ] README contains `## Model Licenses` section +- [ ] Copyright year and entity (`Onde Inference (Splitfire AB)`) is correct +- [ ] mistral.rs fork dependency is pinned to a commit SHA +- [ ] mistral.rs `LICENSE` (Eric Buehler) is intact and unmodified + +--- + +## Quick Reference + +| Question | Answer | +|---|---| +| What license is Onde? | MIT OR Apache-2.0 (user's choice) | +| Who holds the copyright? | Onde Inference (Splitfire AB) | +| Can someone fork Onde and sell it? | Yes, under MIT or Apache 2.0, but they cannot use the "Onde Inference" name or logo | +| Are Qwen models MIT? | No — Qwen Community License. Disclose to users. | +| Can enterprises use Onde commercially? | Yes. Apache 2.0 + Qwen Community License both permit commercial use. | +| Does the patent grant apply? | Yes, under Apache 2.0. Choose Apache 2.0 if patent protection matters to you. | +| Who is Eric Buehler? | Author of mistral.rs. His MIT copyright must be preserved in our fork. | \ No newline at end of file diff --git a/.agents/skills/rust-coding-standard/SKILL.md b/.agents/skills/rust-coding-standard/SKILL.md new file mode 100644 index 0000000..08c9685 --- /dev/null +++ b/.agents/skills/rust-coding-standard/SKILL.md @@ -0,0 +1,116 @@ +--- +name: rust-coding-standard +description: Rust coding standards for the onde crate. Covers error handling, naming, async patterns, platform gating, clippy compliance, and cross-platform SDK conventions. Apply to all Rust edits. +allowed-tools: Read, Write, Edit, Glob, Grep +user-invocable: false +--- + +# Rust Coding Standard + +Rules for writing Rust in the `onde` crate. These are traps to avoid, not maps to follow. + +--- + +## Correctness First + +Prioritize correctness and clarity. Performance is secondary unless explicitly stated. Complete, mostly-correct code beats perfect code that doesn't solve the problem. + +## Error Handling + +- Propagate errors with `?`. Never call `.unwrap()` or `.expect()` outside of `#[cfg(test)]` code. +- Never silently discard errors with `let _ =` on fallible operations: + - Propagate with `?` when the caller should handle it. + - Use `log::warn!` or `log::error!` when you need to ignore but want visibility. + - Use `match` or `if let Err(e)` when custom recovery logic is needed. +- Use `anyhow` for internal application errors in `engine.rs`. +- Use `thiserror` for the `InferenceError` enum — it crosses the FFI boundary via UniFFI. +- Be careful with indexing operations that may panic if out of bounds. Prefer `.get()` and safe alternatives. + +## Naming + +- Use full words for all variable and parameter names. No single-letter names, no abbreviations (`environment` not `env`, `client` not `c`, `message` not `msg`). +- `snake_case` for functions and variables, `CamelCase` for types, `SCREAMING_SNAKE_CASE` for constants. +- Model constants live in `src/inference/models.rs`. Prefix HuggingFace repo IDs with the org name: `BARTOWSKI_QWEN25_...`. + +## Comments + +- Comments explain **why**, never **what**. Do not write comments that summarize or re-describe the code. +- Doc comments (`///`) on public items are fine and encouraged — they explain the contract, not the implementation. +- Do not add organizational separators like `// --- Helper functions ---` unless they are already established in the file's style. + +## Module Structure + +- **No `mod.rs` for new modules.** Use `src/some_module.rs` instead of `src/some_module/mod.rs`. Exception: `inference/mod.rs` already exists and is the intentional module root — don't move it. +- Prefer implementing functionality in existing files unless it's a genuinely new logical component. Avoid creating many small single-purpose files. + +## Async + +- Runtime is `tokio`. All async functions use `tokio::sync::Mutex`, not `std::sync::Mutex`. +- Use variable shadowing to scope `.clone()` calls inside async closures, minimizing borrow lifetimes: + +```rust +tokio::spawn({ + let engine = engine.clone(); + async move { + engine.do_work().await; + } +}); +``` + +## Logging + +- Use `log` crate macros: `log::debug!`, `log::info!`, `log::warn!`, `log::error!`. +- No `println!` or `eprintln!` in library code. Ever. +- Log at `debug` for routine operations, `info` for lifecycle events (model loaded/unloaded), `warn` for recoverable issues, `error` for failures. + +## Platform Gating + +- Use `#[cfg(target_os = "...")]` blocks. Match `Cargo.toml`'s target-conditional dependency sections. +- When a function is only used on specific platforms, gate the function itself — not just the call site. This prevents dead-code warnings on other targets. +- When an import is only used on specific platforms, gate the `use` statement separately. Do not put platform-only imports inside a shared `use` block. +- The supported `target_os` values are: `macos`, `ios`, `tvos`, `windows`, `linux`, `android`. +- ISQ model loading is `#[cfg(target_os = "macos")]` only. Gate the `Isq` variant, its match arms, and its imports (`IsqBits`, `TextModelBuilder`) to macOS. + +## Clippy Compliance + +All code must pass `cargo clippy --all-targets -- -D warnings`. Common traps: + +- `&PathBuf` in function parameters → use `&Path` instead. +- `unwrap_or_else(Type::default)` → use `unwrap_or_default()`. +- `filter_map(|x| { ... Some(y) })` where every path returns `Some` → use `map`. +- `.map(|x| f(x))` where `f` is a plain function → use `.map(f)`. +- Redundant type annotations in closures: `.map(|c: &String| c.trim())` → `.map(|c| c.trim())`. +- Unused imports/variants on platform-gated code → gate the import/variant to the same platform. + +## Types for FFI + +- `usize` / `isize` are not FFI-safe. Use `u64` / `i64` in any type that crosses the UniFFI boundary. +- `impl Into` is not FFI-safe. Use concrete `String` in exported signatures. +- All UniFFI-exported types go in `src/inference/types.rs`. The FFI wrapper lives in `src/inference/ffi.rs`. +- `ChatEngine` (engine.rs) owns Rust-idiomatic logic. `OndeChatEngine` (ffi.rs) is the thin FFI wrapper. Never put `#[uniffi::export]` on `ChatEngine`. + +## Re-exports + +`mistralrs`, `hf_hub`, and `mistralrs_core` are re-exported from `lib.rs` for downstream Rust consumers. Keep these in sync with what's actually available per platform. + +## Testing + +- Unit tests go in a `#[cfg(test)] mod tests` block at the bottom of the file they test. +- Use `#[tokio::test]` for async tests. +- Tests may use `.unwrap()` and `.expect()` — the panics are the assertions. +- Test names describe the scenario: `engine_send_without_model_errors`, not `test_send` or `test_1`. +- Run `cargo test --lib` to skip integration tests that require model downloads. + +## Adding a New Model + +This is a common task with a specific checklist: + +1. Add `pub const` entries in `src/inference/models.rs` for repo ID, GGUF filename, and `TOK_MODEL_ID`. +2. Add the repo ID to `SUPPORTED_MODELS`. +3. Add a `SupportedModelInfo` entry to `SUPPORTED_MODEL_INFO` with `expected_size_bytes` from the HF API. +4. Add a constructor to `GgufModelConfig` in `engine.rs` (follow `qwen25_1_5b` pattern). +5. Export a free function in `ffi.rs` (follow `qwen25_1_5b_config` pattern). + +## Build Verification + +Always run `cargo check` before returning work. If code does not compile, fix it — do not return with TODOs. On macOS, also run `cargo clippy --all-targets -- -D warnings` to catch lint issues before CI does. \ No newline at end of file diff --git a/.agents/skills/sdk-swift-package/SKILL.md b/.agents/skills/sdk-swift-package/SKILL.md new file mode 100644 index 0000000..eebee5e --- /dev/null +++ b/.agents/skills/sdk-swift-package/SKILL.md @@ -0,0 +1,363 @@ +--- +name: sdk-swift-package +description: Build the Onde Swift package (XCFramework) from Rust source using UniFFI and distribute it via a remote-binary Package.swift for Swift Package Index. Covers xcframework assembly, App Group shared container, models/hub cache convention, and the onde-swift release workflow. +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +user-invocable: true +--- + +# Skill: Swift Package (XCFramework Distribution) + +## What this skill covers + +Building the `Onde` Swift package from Rust source using UniFFI, assembling an +`XCFramework`, and distributing it via a remote-binary `Package.swift` for +Swift Package Index. + +--- + +## Repository layout + +``` +onde/ +├── src/ # Rust source (lib.rs, inference/, hf_cache.rs) +├── sdk/Onde/ # Output: generated Swift package (git-ignored locally) +│ ├── Package.swift +│ ├── Sources/Onde/ # UniFFI-generated onde.swift glue + header +│ └── OndeFramework.xcframework +├── uniffi-bindgen/ # onde's own uniffi-bindgen CLI binary +│ ├── Cargo.toml +│ └── uniffi-bindgen.rs # fn main() { uniffi::uniffi_bindgen_main() } +├── uniffi.toml # UniFFI binding config (package_name, cdylib_name) +├── build.rs # Provides tvOS ___chkstk_darwin stub via cc +└── Cargo.toml # uniffi = "=0.31.0" — version MUST stay pinned +``` + +--- + +## Key constraints + +| Constraint | Detail | +|---|---| +| UniFFI version | Pinned to **`=0.31.0`** in `Cargo.toml` and `uniffi-bindgen/Cargo.toml`. Never bump without coordinating with the Kotlin and Python SDKs. | +| tvOS targets | Tier-3 — require `cargo +nightly -Z build-std`. Stable toolchain is used for iOS/macOS. | +| tvOS linker | `___chkstk_darwin` is missing from tvOS libSystem. `build.rs` compiles `tvos_chkstk.s` as a no-op stub. This is automatic — do not remove it. | +| `cargo-swift` | **Not required.** The project owns its own `uniffi-bindgen` binary. Use it directly (same as `build-kotlin.sh` does for Android). | +| `.xcframework` in `Package.swift` | Published `Package.swift` must use **remote** `url:` + `checksum:` — never a local `path:`. | +| Swift package name | `Onde` (PascalCase). Git repo slug: `onde-swift` under the `ondeinference` org. | +| App Group ID | `group.com.ondeinference.apps` — shared across all Onde-powered apps (Flutter, Tauri, native Swift). | +| HF cache subdirectory | `/models/hub/` — NOT `huggingface/hub/`. Must match the Tauri and Dart SDK convention. | + +--- + +## Apple target triples + +| Slice | Triple | Toolchain | +|---|---|---| +| iOS device | `aarch64-apple-ios` | stable | +| iOS simulator (Intel) | `x86_64-apple-ios` | stable | +| iOS simulator (Apple Silicon) | `aarch64-apple-ios-sim` | stable | +| tvOS device | `aarch64-apple-tvos` | nightly + `-Z build-std` | +| tvOS simulator (Intel) | `x86_64-apple-tvos` | nightly + `-Z build-std` | +| tvOS simulator (Apple Silicon) | `aarch64-apple-tvos-sim` | nightly + `-Z build-std` | +| macOS (Apple Silicon) | `aarch64-apple-darwin` | stable | +| macOS (Intel) | `x86_64-apple-darwin` | stable | + +--- + +## Build sequence (manual / CI) + +### 1. Build the `uniffi-bindgen` CLI (host) + +```bash +cargo build --manifest-path uniffi-bindgen/Cargo.toml --release +BINDGEN=uniffi-bindgen/target/release/uniffi-bindgen # or workspace target/ +``` + +### 2. Compile Rust staticlibs per Apple target + +```bash +# Stable targets (iOS, macOS) +cargo build --target aarch64-apple-ios --release +cargo build --target x86_64-apple-ios --release +cargo build --target aarch64-apple-ios-sim --release +cargo build --target aarch64-apple-darwin --release +cargo build --target x86_64-apple-darwin --release + +# Nightly targets (tvOS — tier 3) +cargo +nightly build -Z build-std \ + --target aarch64-apple-tvos --release +cargo +nightly build -Z build-std \ + --target x86_64-apple-tvos --release +cargo +nightly build -Z build-std \ + --target aarch64-apple-tvos-sim --release +``` + +### 3. lipo simulator slices + +```bash +# iOS simulator fat lib +lipo -create \ + target/x86_64-apple-ios/release/libonde.a \ + target/aarch64-apple-ios-sim/release/libonde.a \ + -output /tmp/libonde-ios-sim.a + +# tvOS simulator fat lib +lipo -create \ + target/x86_64-apple-tvos/release/libonde.a \ + target/aarch64-apple-tvos-sim/release/libonde.a \ + -output /tmp/libonde-tvos-sim.a + +# macOS universal fat lib +lipo -create \ + target/aarch64-apple-darwin/release/libonde.a \ + target/x86_64-apple-darwin/release/libonde.a \ + -output /tmp/libonde-macos.a +``` + +### 4. Generate Swift bindings + +```bash +mkdir -p sdk/Onde/Sources/Onde + +$BINDGEN generate \ + --library target/aarch64-apple-ios/release/libonde.a \ + --language swift \ + --out-dir sdk/Onde/Sources/Onde \ + --config uniffi.toml +``` + +This produces `onde.swift` and `ondeFFI.h` (+ `ondeFFI.modulemap`). + +### 5. Create the `.xcframework` + +Each slice needs a headers directory alongside its `.a`: + +```bash +HEADERS=sdk/Onde/Sources/Onde # contains ondeFFI.h + ondeFFI.modulemap + +xcodebuild -create-xcframework \ + -library target/aarch64-apple-ios/release/libonde.a -headers $HEADERS \ + -library /tmp/libonde-ios-sim.a -headers $HEADERS \ + -library target/aarch64-apple-tvos/release/libonde.a -headers $HEADERS \ + -library /tmp/libonde-tvos-sim.a -headers $HEADERS \ + -library /tmp/libonde-macos.a -headers $HEADERS \ + -output sdk/Onde/OndeFramework.xcframework +``` + +### 6. Zip and compute checksum + +```bash +cd sdk/Onde +zip -r OndeFramework.xcframework.zip OndeFramework.xcframework +CHECKSUM=$(shasum -a 256 OndeFramework.xcframework.zip | cut -d ' ' -f1) +echo $CHECKSUM +``` + +### 7. Publish (two-repo strategy) + +1. Upload `OndeFramework.xcframework.zip` as a GitHub Release asset on the + Rust source repo. +2. In the `ondeinference/onde-kit` Swift-only repo, update `Package.swift`: + - `url:` → the GitHub Release asset download URL + - `checksum:` → the SHA-256 from step 6 +3. Commit, tag the version (`0.1.0`, etc.), and push to `onde-kit`. +4. Swift Package Index indexes `onde-kit` on every new tag. + +--- + +## `Package.swift` (published form) + +```swift +// swift-tools-version:5.5 +import PackageDescription + +let package = Package( + name: "Onde", + platforms: [ + .iOS(.v14), + .macOS(.v12), + .tvOS(.v13), + ], + products: [ + .library(name: "Onde", targets: ["Onde"]) + ], + targets: [ + .binaryTarget( + name: "OndeFramework", + url: "https://github.com/ondeinference/onde-kit/releases/download/0.1.0/OndeFramework.xcframework.zip", + checksum: "" + ), + .target( + name: "Onde", + dependencies: [.target(name: "OndeFramework")] + ), + ] +) +``` + +--- + +## UniFFI type map (Rust → Swift) + +| Rust type | UniFFI derive | Swift type | +|---|---|---| +| `ChatRole` | `uniffi::Enum` | `enum ChatRole` | +| `ChatMessage` | `uniffi::Record` | `struct ChatMessage` | +| `SamplingConfig` | `uniffi::Record` | `struct SamplingConfig` | +| `GgufModelConfig` | `uniffi::Record` | `struct GgufModelConfig` | +| `InferenceResult` | `uniffi::Record` | `struct InferenceResult` | +| `StreamChunk` | `uniffi::Record` | `struct StreamChunk` | +| `EngineStatus` | `uniffi::Enum` | `enum EngineStatus` | +| `EngineInfo` | `uniffi::Record` | `struct EngineInfo` | +| `InferenceError` | `uniffi::Error` | `enum OndeError : Error` | +| `OndeChatEngine` | `uniffi::Object` | `class OndeChatEngine` | +| `StreamChunkListener` | `callback_interface` | `protocol StreamChunkListener` | + +**UniFFI compatibility rules:** +- Use `u64`/`u32` instead of `usize` (UniFFI does not support `usize`). +- Streaming is exposed as a **free function** (`streamChatMessage`) not an + Object method — UniFFI 0.31 does not support `callback_interface` in Object + method parameters. +- Use concrete `String` parameters in `OndeChatEngine` methods — `impl Into` + does not cross the FFI boundary. + +--- + +## Swift API quick reference + +```swift +import Onde + +// Engine lifecycle +let engine = OndeChatEngine() +try await engine.loadDefaultModel(systemPrompt: "You are helpful.", sampling: nil) +try await engine.loadGgufModel(config: qwen251_5bConfig(), systemPrompt: nil, sampling: nil) +await engine.unloadModel() +let loaded: Bool = await engine.isLoaded() +let info: EngineInfo = await engine.info() + +// Chat +let result: InferenceResult = try await engine.sendMessage(message: "Hello!") +let oneShot: InferenceResult = try await engine.generate(messages: [...], sampling: nil) + +// History +let history: [ChatMessage] = await engine.history() +let removed: UInt64 = await engine.clearHistory() +await engine.pushHistory(message: userMessage(content: "...")) + +// Streaming (free function, callback-based) +try await streamChatMessage(engine: engine, message: "Tell me a story.", listener: myHandler) + +// Config free functions +defaultModelConfig() // platform-appropriate default +qwen251_5bConfig() // ~941 MB, iOS/tvOS/Android +qwen253bConfig() // ~1.93 GB, macOS/desktop + +// Sampling presets +defaultSamplingConfig() // temp=0.7, top_p=0.95, max=512 +deterministicSamplingConfig() // temp=0.0 +mobileSamplingConfig() // temp=0.7, max=128 + +// Message constructors +systemMessage(content:) +userMessage(content:) +assistantMessage(content:) +``` + +--- + +## App Group Shared Container & Cache Convention + +All Onde-powered apps use the App Group `group.com.ondeinference.apps` to share +downloaded models. The cache layout inside the shared container is: + +``` +/ +├── models/ ← HF_HOME +│ └── hub/ ← HF_HUB_CACHE (GGUF files live here) +└── tmp/ ← TMPDIR (iOS sandbox restricts system TMPDIR) +``` + +**Important:** The subdirectory is `models/`, NOT `huggingface/`. This was +chosen to be user-friendly when browsing the container in Finder/Files. All +SDKs (Rust, Swift, Dart) and all app types (Tauri, Flutter, native) must use +the same path or models won't be shared. + +### Swift setup (called once at app launch) + +```swift +import Foundation + +func setupInferenceEnvironment() { + guard let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.com.ondeinference.apps" + ) else { + // Fall back to app-private Application Support if App Group is unavailable. + let appSupport = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + setupHfCache(at: appSupport) + return + } + setupHfCache(at: container) +} + +private func setupHfCache(at base: URL) { + let hfHome = base.appendingPathComponent("models") + let hfHub = hfHome.appendingPathComponent("hub") + try? FileManager.default.createDirectory(at: hfHub, withIntermediateDirectories: true) + + setenv("HF_HOME", hfHome.path, 1) + setenv("HF_HUB_CACHE", hfHub.path, 1) + + let tmp = base.appendingPathComponent("tmp") + try? FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + setenv("TMPDIR", tmp.path, 1) +} +``` + +### Entitlements required + +Both iOS and macOS targets need the App Group entitlement: + +```xml +com.apple.security.application-groups + + group.com.ondeinference.apps + +``` + +The App Group must also be registered in the Apple Developer Portal under +**Identifiers → App Groups** before Xcode can provision it. + +--- + +## Local development (Xcode) + +Use the **local path** form during development — the remote binary form is only +needed for distribution: + +```swift +// Package.swift (local dev only — never commit to onde-kit) +.binaryTarget(name: "OndeFramework", path: "./OndeFramework.xcframework") +``` + +In Xcode: **File → Add Package Dependencies → Add Local** → select `sdk/Onde/`. + +--- + +## Common pitfalls + +| Pitfall | Fix | +|---|---| +| Local `path:` in published `Package.swift` | Always `url:` + `checksum:` in `onde-swift` | +| Stale checksum after rebuild | Always recompute `shasum -a 256` — never hardcode | +| Missing simulator slice | lipo `x86_64` + `aarch64-sim` before passing to `xcodebuild` | +| UniFFI version drift | Keep `uniffi = "=0.31.0"` in both `Cargo.toml` and `uniffi-bindgen/Cargo.toml` | +| tvOS build with stable toolchain | tvOS targets are tier-3; always use `cargo +nightly -Z build-std` | +| Empty `Sources/` in `onde-swift` | Copy the generated `onde.swift` (and header) into the repo on every release | +| `___chkstk_darwin` linker error | Ensure `tvos_chkstk.s` exists at crate root; `build.rs` compiles it automatically | +| Missing Metal toolchain (Xcode 26+) | Run `xcodebuild -downloadComponent MetalToolchain`, then `cargo clean -p mistralrs-quant` | +| Cache path mismatch (`huggingface/` vs `models/`) | All SDKs MUST use `/models/hub/` — using `huggingface/hub/` means models are not shared across apps | +| App Group not working | Register `group.com.ondeinference.apps` in Apple Developer Portal → Identifiers → App Groups; enable App Groups capability on the App ID; add entitlement to the target | +| `CODE_SIGN_IDENTITY = "-"` blocks App Groups | Remove ad-hoc signing from project-level build settings; App Groups require a real development certificate | +| Android NDK `LD` on PATH breaks Xcode | Never `export LD=$TOOLCHAIN/bin/ld` globally — it hijacks Xcode's linker. Use Cargo's `.cargo/config.toml` for Android linker config | \ No newline at end of file diff --git a/.agents/skills/uniffi-rs/SKILL.md b/.agents/skills/uniffi-rs/SKILL.md new file mode 100644 index 0000000..58c4a25 --- /dev/null +++ b/.agents/skills/uniffi-rs/SKILL.md @@ -0,0 +1,167 @@ +--- +name: uniffi-rs +description: Generate Swift, Kotlin, and Python bindings from Rust using UniFFI. Use when adding, modifying, or debugging FFI-exported types and functions in the onde crate. +allowed-tools: Read, Write, Edit, Glob, Grep +user-invocable: false +--- + +# uniffi-rs + +## What This Skill Covers + +How UniFFI is used in the `onde` crate to generate Swift, Kotlin, and Python bindings from a single Rust implementation. + +## Version Pin + +UniFFI is pinned to exactly `=0.31.0` everywhere. This version must stay in sync across: +- `onde/Cargo.toml` → `uniffi = { version = "=0.31.0", features = ["tokio"] }` +- `onde/Cargo.toml` build-deps → `uniffi = { version = "=0.31.0", features = ["build"] }` +- `onde/uniffi-bindgen/Cargo.toml` → `uniffi = { version = "=0.31.0", features = ["cli"] }` + +Never bump one without bumping all three. Mismatched versions produce silently broken bindings. + +## Project Structure + +``` +onde/ +├── src/lib.rs # uniffi::setup_scaffolding!() +├── src/inference/ +│ ├── types.rs # #[derive(uniffi::Record/Enum/Error)] +│ └── ffi.rs # #[derive(uniffi::Object)], #[uniffi::export] +├── uniffi-bindgen/ +│ ├── Cargo.toml # Standalone bin crate for the bindgen CLI +│ └── uniffi-bindgen.rs # fn main() { uniffi::uniffi_bindgen_main() } +├── uniffi.toml # Binding config (package names, cdylib name) +└── build.rs # uniffi build support (build-dep) +``` + +## Scaffolding Setup + +`src/lib.rs` uses the proc-macro approach: + +```rust +uniffi::setup_scaffolding!(); +``` + +This is equivalent to the UDL approach but requires no `.udl` file. The `build.rs` still declares the `uniffi` build-dep to keep compatibility if the project ever switches to UDL. + +## Type Annotations + +| Rust construct | UniFFI derive | Crosses FFI as | +|---|---|---| +| Plain data struct | `#[derive(uniffi::Record)]` | Swift `struct` / Kotlin `data class` | +| C-like / rich enum | `#[derive(uniffi::Enum)]` | Swift `enum` / Kotlin `sealed class` | +| `thiserror::Error` enum | `#[derive(uniffi::Error)]` | Swift `Error enum` / Kotlin exception | +| `Arc` wrapper struct | `#[derive(uniffi::Object)]` | Swift `class` / Kotlin `object` | +| Trait for callbacks | `#[uniffi::export(callback_interface)]` | Swift `protocol` / Kotlin `interface` | + +All exported functions and impl blocks are annotated with `#[uniffi::export]`. + +## Type Compatibility Rules + +These constraints come directly from UniFFI 0.31 limitations: + +- **`usize` / `isize` are not supported** — use `u64` / `i64` instead. +- **`impl Into` is not FFI-safe** — use concrete `String` in all exported method signatures. +- **`tokio::sync::mpsc::Receiver` is not FFI-safe** — streaming is exposed via a `callback_interface` trait delivered through a free function, not an Object method. +- **`callback_interface` parameters must be on free functions** — UniFFI 0.31 does not support `callback_interface` arguments on `#[uniffi::Object]` methods. +- **Generics are not supported** — all exported types must be fully concrete. + +## The uniffi-bindgen CLI + +`onde/uniffi-bindgen/` is a minimal standalone binary crate: + +```rust +// uniffi-bindgen.rs +fn main() { + uniffi::uniffi_bindgen_main() +} +``` + +Build it once and run it to generate bindings: + +```bash +cargo build --manifest-path uniffi-bindgen/Cargo.toml --release + +# Generate Swift +./target/release/uniffi-bindgen generate \ + --library target/aarch64-apple-tvos/release/libonde.a \ + --language swift \ + --out-dir sdk/Onde/Sources/Onde \ + --config uniffi.toml + +# Generate Kotlin +./target/release/uniffi-bindgen generate \ + --library target/aarch64-linux-android/release/libonde.so \ + --language kotlin \ + --out-dir sdk/kotlin/onde/src/main/kotlin \ + --config uniffi.toml + +# Generate Python +./target/release/uniffi-bindgen generate \ + --library target/release/libonde.dylib \ + --language python \ + --out-dir sdk/python \ + --config uniffi.toml +``` + +The `--library` argument points to a compiled artifact (`.a`, `.so`, or `.dylib`). UniFFI reads the embedded metadata from the binary — any compiled artifact from any platform works for generating bindings for any language. + +## uniffi.toml + +```toml +[bindings.kotlin] +package_name = "com.ondeinference.onde" +cdylib_name = "onde" + +[bindings.python] +cdylib_name = "onde" +``` + +No Swift-specific config is needed here. `cargo-swift` (when used) and the raw `uniffi-bindgen` both derive Swift names from the Rust struct/function names. + +## Async Support + +UniFFI 0.31 supports `async fn` exports when the crate enables the `tokio` feature: + +```toml +uniffi = { version = "=0.31.0", features = ["tokio"] } +``` + +All `async fn` methods on `#[uniffi::Object]` and all `async` free functions annotated with `#[uniffi::export]` are automatically bridged to Swift `async throws` and Kotlin `suspend fun`. + +## Streaming Pattern + +Because `callback_interface` is not allowed on Object methods in UniFFI 0.31, streaming is exposed as a top-level free function that accepts the Object by `Arc`: + +```rust +// CORRECT — free function with Arc +#[uniffi::export] +pub async fn stream_chat_message( + engine: Arc, + message: String, + listener: Box, // callback_interface +) -> Result<(), InferenceError> { ... } + +// WRONG — callback_interface on Object method (not supported in 0.31) +// impl OndeChatEngine { +// pub async fn stream(&self, listener: Box) { ... } +// } +``` + +## Adding a New Exported Type + +1. Define the type in `src/inference/types.rs` (or whichever module owns it). +2. Add the appropriate `#[derive(uniffi::Record)]` / `#[derive(uniffi::Enum)]` / `#[derive(uniffi::Error)]`. +3. Replace any `usize` fields with `u64`, and any `isize` with `i64`. +4. If the type needs to be exported from the FFI Object, add it to the method signatures in `src/inference/ffi.rs`. +5. Recompile for any Apple target and re-run `uniffi-bindgen generate` for Swift. +6. Re-run `build-kotlin.sh` for Kotlin. + +## Common Pitfalls + +- **Version mismatch**: If the `uniffi` version in `onde/Cargo.toml` differs from the one in `onde/uniffi-bindgen/Cargo.toml`, the generated bindings will be incompatible with the compiled library at runtime. Always keep them identical with the `=` exact pin. +- **Forgetting `setup_scaffolding!()`**: Without `uniffi::setup_scaffolding!()` in `lib.rs`, no scaffolding code is generated and all `#[uniffi::export]` annotations are silently ignored. +- **`usize` in struct fields**: UniFFI will produce a hard compile error or a confusing runtime panic if `usize` appears in any exported type. Always use `u64`. +- **Callback interface in Object method**: This compiles fine but panics at runtime in UniFFI 0.31. Move the method to a free function. +- **Not rebuilding the bindgen binary**: If you update `uniffi` in the main `Cargo.toml` without rebuilding `uniffi-bindgen`, the CLI will use the old schema and generate stale/broken bindings. \ No newline at end of file diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.sigit/code-cloud-agent/config.toml b/.sigit/code-cloud-agent/config.toml new file mode 100644 index 0000000..291319b --- /dev/null +++ b/.sigit/code-cloud-agent/config.toml @@ -0,0 +1,2 @@ +[agent] +model = "onde-balance" diff --git a/.sigit/code-review/config.toml b/.sigit/code-review/config.toml new file mode 100644 index 0000000..27cdf29 --- /dev/null +++ b/.sigit/code-review/config.toml @@ -0,0 +1,3 @@ +[agent] +login = "sigit-code-review" +display_name = "siGit Code Review" diff --git a/.sigit/factory/config.toml b/.sigit/factory/config.toml new file mode 100644 index 0000000..771b497 --- /dev/null +++ b/.sigit/factory/config.toml @@ -0,0 +1,9 @@ +[agent] +model = "onde-large" + +[harvest] +state = "closed" +limit = 50 +since = "2026-09-01" +cadence = "weekly" +day_of_week = "Mon" diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..41280d2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +.agents/AGENTS.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..41280d2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +.agents/AGENTS.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 93a807a..3a14fba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,56 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "agent-client-protocol" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13f780597b2c88ab83d02509bcc3e86366751f86fa12114005e35c55ed691763" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-io", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "rustc-hash 2.1.3", + "rustix", + "schemars 1.2.2", + "serde", + "serde_json", + "shell-words", + "tracing", + "uuid 1.27.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4893bfb11e0e193aa5a64611a126ee87567acb1879556519c902722899229de" +dependencies = [ + "quote", + "syn 3.0.6", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d595a79e1665d02c91c7dd1bedd118501e75cc0a3d5b1651b5150ec4c5c1e0d" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.2", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "tracing", +] + [[package]] name = "ahash" version = "0.8.12" @@ -272,6 +322,18 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compat" version = "0.2.6" @@ -285,6 +347,77 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.92" @@ -408,6 +541,58 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.13.1" @@ -525,6 +710,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bm25" version = "2.3.2" @@ -592,6 +790,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.2" @@ -961,6 +1165,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.15.11" @@ -1108,6 +1321,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -1152,6 +1371,7 @@ dependencies = [ "crossterm_winapi", "derive_more", "document-features", + "futures-core", "mio", "parking_lot", "rustix", @@ -1530,6 +1750,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -1562,6 +1783,15 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1726,11 +1956,56 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" +[[package]] +name = "ed-acp" +version = "1.2.2" +dependencies = [ + "agent-client-protocol", + "anyhow", + "async-trait", + "base64 0.23.1", + "directories", + "ed-acp", + "ed-mcp", + "futures", + "onde", + "reqwest 0.13.5", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "uuid 1.27.0", +] + +[[package]] +name = "ed-acp-testkit" +version = "1.2.2" +dependencies = [ + "serde_json", + "tokio", + "uuid 1.27.0", +] + +[[package]] +name = "ed-acp-tui" +version = "1.2.2" +dependencies = [ + "agent-client-protocol", + "anyhow", + "crossterm", + "futures", + "ratatui", + "tokio", +] + [[package]] name = "ed-agent" -version = "1.0.1" +version = "1.2.2" dependencies = [ "async-trait", + "futures-core", "log", "onde", "serde", @@ -1741,7 +2016,7 @@ dependencies = [ [[package]] name = "ed-agent-ffi" -version = "1.0.1" +version = "1.2.2" dependencies = [ "async-trait", "cc", @@ -1750,17 +2025,53 @@ dependencies = [ "serde_json", "smbcloud-gresiq-sdk", "thiserror 2.0.21", + "tokio", "uniffi", ] [[package]] name = "ed-agent-tauri" -version = "1.0.1" +version = "1.2.2" dependencies = [ + "async-trait", "ed-agent", "log", "serde", + "serde_json", "tauri", + "tokio", + "uuid 1.27.0", +] + +[[package]] +name = "ed-mcp" +version = "1.2.2" +dependencies = [ + "agent-client-protocol", + "anyhow", + "async-trait", + "axum", + "ed-agent", + "ed-mcp", + "futures", + "http", + "rmcp", + "serde_json", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "ed-onde-account" +version = "1.2.2" +dependencies = [ + "axum", + "reqwest 0.13.5", + "serde", + "serde_json", + "thiserror 2.0.21", + "tokio", ] [[package]] @@ -1925,6 +2236,26 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "exr" version = "1.74.2" @@ -2007,6 +2338,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -2134,6 +2471,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec 1.16.2", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -2157,6 +2507,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -2864,6 +3227,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -3026,6 +3394,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.11.1" @@ -3040,6 +3414,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec 1.16.2", @@ -3324,6 +3699,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "infer" version = "0.22.0" @@ -3333,6 +3717,19 @@ dependencies = [ "cfb", ] +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling 0.24.1", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -3578,6 +3975,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.21", +] + [[package]] name = "keyboard-types" version = "0.8.3" @@ -3695,6 +4103,15 @@ dependencies = [ "libc", ] +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3780,6 +4197,15 @@ dependencies = [ "vob", ] +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.3" @@ -3865,6 +4291,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "matrixmultiply" version = "0.3.11" @@ -4320,6 +4752,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -4570,9 +5011,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onde" -version = "1.3.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7145ab132138de1f7b57707d37a5f7dbcb0dee36560742689270c162359478b5" +checksum = "cc87dc77609c5ed63840b263bc548acf50c1a6992a0a0834c032482e3cddc63b" dependencies = [ "anyhow", "cc", @@ -5014,6 +5455,39 @@ dependencies = [ "num-traits", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "pango" version = "0.18.3" @@ -5039,6 +5513,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -5139,12 +5619,43 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -5196,6 +5707,20 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.3", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -5312,6 +5837,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "10.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f21b97672d2dc848e7b25701ab4535618b92f4861c13cc3f7f7bed52ad3c8da" +dependencies = [ + "futures", + "indexmap 2.14.2", + "nix", + "tokio", + "tracing", + "windows 0.62.2", +] + [[package]] name = "profiling" version = "1.0.18" @@ -5600,6 +6139,73 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.21", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.2", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "rav1e" version = "0.8.1" @@ -5902,6 +6508,39 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "indexmap 2.14.2", + "pastey 0.2.3", + "pin-project-lite", + "process-wrap", + "rand 0.10.3", + "reqwest 0.13.5", + "schemars 1.2.2", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid 1.27.0", +] + [[package]] name = "rubato" version = "0.16.2" @@ -6165,6 +6804,7 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ + "chrono", "dyn-clone", "ref-cast", "schemars_derive 1.2.2", @@ -6436,6 +7076,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_plain" version = "1.0.2" @@ -6581,6 +7232,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -6844,6 +7501,19 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -7701,7 +8371,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -7854,6 +8526,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -8040,6 +8723,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -8078,6 +8762,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -8296,6 +8981,17 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -8308,6 +9004,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unicode_categories" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index d5ec0e4..ef4c663 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,23 +1,33 @@ [workspace] resolver = "2" -members = ["crates/ed-agent", "crates/ed-agent-ffi", "crates/ed-agent-tauri", "uniffi-bindgen"] +members = [ + "crates/ed-agent", + "crates/ed-agent-ffi", + "crates/ed-agent-tauri", + "crates/ed-mcp", + "crates/ed-acp", + "crates/ed-acp-tui", + "crates/ed-acp-testkit", + "crates/ed-onde-account", + "uniffi-bindgen", +] # Shared across every crate in the workspace so a release moves one number. # `sync-version` tooling (see the Makefile) reads these. [workspace.package] -version = "1.0.1" +version = "1.2.2" edition = "2021" rust-version = "1.94" license = "MIT OR Apache-2.0" -authors = ["Splitfire AB"] +authors = ["Onde Inference"] repository = "https://github.com/ondeinference/ed" homepage = "https://ondeinference.com" [workspace.dependencies] -# The inference engine Ed wraps. Cargo uses the sibling checkout locally and -# publishes only the version requirement. Release Onde's tool-calling API -# before publishing Ed. -onde = { version = "1.3.1" } +# The inference engine Ed wraps, resolved from crates.io like any dependency +# (there is no path override to a sibling checkout). 1.3.2 is the first release +# with ChatEngine::complete, which ed-acp's local provider calls. +onde = { version = "1.3.2" } async-trait = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -31,8 +41,29 @@ tauri = { version = "2", features = [] } uniffi = { version = "=0.31.0", features = ["tokio"] } smbcloud-gresiq-sdk = "0.4.7" +# Onde Agent Platform v0.1 baseline (onde-web .agents/specs/onde-agent-platform, §5.4). +# The ACP crates (ed-mcp, ed-acp, ed-acp-tui, ed-acp-testkit) use these; ed-agent does not. +agent-client-protocol = "2.2" +rmcp = { version = "3.5", default-features = false } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "stream"] } +ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "layout-cache", "unstable-rendered-line-info"] } +crossterm = { version = "0.29", features = ["event-stream"] } +tokio-util = "0.7" +futures = "0.3" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v4"] } +directories = "6" +base64 = "0.23" + # Internal -ed-agent = { version = "1.0.1", path = "crates/ed-agent" } +ed-agent = { version = "1.2.2", path = "crates/ed-agent" } +ed-mcp = { version = "1.2.2", path = "crates/ed-mcp" } +ed-acp = { version = "1.2.2", path = "crates/ed-acp" } +ed-acp-tui = { version = "1.2.2", path = "crates/ed-acp-tui" } +ed-acp-testkit = { version = "1.2.2", path = "crates/ed-acp-testkit" } +ed-onde-account = { version = "1.2.2", path = "crates/ed-onde-account" } # gemm-common (via candle, via onde) depends on sysctl for every # `target_vendor = "apple"`, but sysctl 0.6.0 only has a `sys` module behind diff --git a/LICENSE-APACHE b/LICENSE-APACHE index 58885ec..bdc140a 100644 --- a/LICENSE-APACHE +++ b/LICENSE-APACHE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Splitfire AB (Onde Inference) + Copyright 2026 Onde Inference Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/LICENSE-MIT b/LICENSE-MIT index 1b62ae8..4b6964c 100644 --- a/LICENSE-MIT +++ b/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Splitfire AB (Onde Inference) +Copyright (c) 2026 Onde Inference Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 4e6e192..fba4de1 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Metal and CPU backends, tokens out. Ed is the part above it your users touch. ```toml [dependencies] -ed-agent = "0.1" +ed-agent = "1.2" ``` ```rust @@ -57,6 +57,13 @@ let reply = ed.send("Summarise this thread.").await; println!("{}", reply.reply.unwrap_or_default()); ``` +Stream a reply with `Ed::stream` (plain chat) or let `Ed::run` forward deltas to +`EventSink::text_delta`. `Ed::restore_history` loads a saved conversation, +`Ed::generate` is a one-shot call that leaves the history alone, and +`Ed::set_system_prompt` / `set_sampling` change the model's settings. These +three need a loaded model. Persistence stays yours; implement `SessionStore` +over whatever you already have. + Construct with `Ed::with_agent` to provide the executor for registered tools and the approval handler for mutating tools. Read-only tools run directly; mutating tools must receive `AllowOnce` or `AllowForSession` first. @@ -67,11 +74,22 @@ mutating tools must receive `AllowOnce` or `AllowForSession` first. | --- | --- | | [`ed-agent`](crates/ed-agent) | The agent. No framework dependency. | | [`ed-agent-ffi`](crates/ed-agent-ffi) | UniFFI bridge used by the Swift XCFramework. | -| [`ed-agent-tauri`](crates/ed-agent-tauri) | Tauri bindings: a sink that emits to the webview, plus the commands it invokes. | +| [`ed-agent-tauri`](crates/ed-agent-tauri) | Tauri bindings: a sink that emits to the webview, the commands it invokes, and `EdSessions` for apps with a chat history and webview-answered approvals. | + +The Onde Agent Platform crates, for agents that editors drive over the Agent Client Protocol +(ACP). SplitFire Agent is built on them. + +| Crate | What it's for | +| --- | --- | +| [`ed-acp`](crates/ed-acp) | An ACP v1 server: sessions, auth, the turn loop on Onde Cloud, tool approval, workspace tools and MCP. A product supplies a `Profile`. | +| [`ed-acp-tui`](crates/ed-acp-tui) | A terminal UI for any ACP agent. | +| [`ed-mcp`](crates/ed-mcp) | MCP client glue over `rmcp`, the official Rust SDK: stdio and streamable HTTP servers, bearer tokens, tool namespacing, timeouts, progress and cancellation. With the `agent` feature, `McpExecutor` serves a toolset to Ed as tools. | +| [`ed-onde-account`](crates/ed-onde-account) | Onde Inference account client: sign in, activate an app and get its Onde Cloud API key through ondeinference.com, with no Onde secrets in the host app. | +| [`ed-acp-testkit`](crates/ed-acp-testkit) | Mock Onde Cloud endpoint, raw ACP client, the ACP registry probe and the ACP v1 conformance suite. | Free the intelligence. ## Copyright -© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)). +© 2026 [Onde Inference](https://ondeinference.com). Licensed under MIT or Apache-2.0. diff --git a/crates/ed-acp-testkit/Cargo.toml b/crates/ed-acp-testkit/Cargo.toml new file mode 100644 index 0000000..3be9b58 --- /dev/null +++ b/crates/ed-acp-testkit/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ed-acp-testkit" +description = "Test kit for Onde ACP agents: a mock Onde Cloud endpoint, a raw JSON-RPC client for the agent binary, a test MCP server, the ACP registry probe and the ACP v1 conformance suite." +keywords = ["acp", "agent", "testing", "conformance", "onde"] +categories = ["development-tools::testing"] +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +tokio = { workspace = true, features = ["full"] } +serde_json.workspace = true +uuid.workspace = true diff --git a/crates/ed-acp-testkit/LICENSE-APACHE b/crates/ed-acp-testkit/LICENSE-APACHE new file mode 100644 index 0000000..bdc140a --- /dev/null +++ b/crates/ed-acp-testkit/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Onde Inference + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/ed-acp-testkit/LICENSE-MIT b/crates/ed-acp-testkit/LICENSE-MIT new file mode 100644 index 0000000..4b6964c --- /dev/null +++ b/crates/ed-acp-testkit/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Onde Inference + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/ed-acp-testkit/README.md b/crates/ed-acp-testkit/README.md new file mode 100644 index 0000000..34b1254 --- /dev/null +++ b/crates/ed-acp-testkit/README.md @@ -0,0 +1,17 @@ +# ed-acp-testkit + +Test support for Onde ACP agents, used as a dev-dependency. + +- A mock Onde Cloud endpoint (streaming, tool calls, usage, `content_filter`). +- A raw JSON-RPC client that drives the real agent binary over stdio. +- The ACP registry validator's `initialize` probe. +- The ACP v1 conformance suite, adopted with one macro call in an integration test: + `ed_acp_testkit::conformance_suite!(target);` + +The scriptable MCP server for tests is in [`ed-mcp`](https://crates.io/crates/ed-mcp) behind its +`test-server` feature. + +## Copyright + +© 2026 [Onde Inference](https://ondeinference.com). +Licensed under MIT or Apache-2.0. diff --git a/crates/ed-acp-testkit/src/conformance.rs b/crates/ed-acp-testkit/src/conformance.rs new file mode 100644 index 0000000..d6b67fa --- /dev/null +++ b/crates/ed-acp-testkit/src/conformance.rs @@ -0,0 +1,795 @@ +//! The ACP v1 conformance suite (Onde Agent Platform §5.5). Each function drives the real +//! agent binary over stdio against the mock Onde endpoint. Run them all with +//! [`conformance_suite!`](crate::conformance_suite). +//! +//! The suite assumes the agent offers [`WorkspaceTools`] (`read_file`, `write_file`) and +//! terminal auth, as every Onde ACP agent does today. +//! +//! [`WorkspaceTools`]: https://docs.rs/ed-acp + +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::Target; +use crate::mock::{MODEL, Step, TEST_KEY, mock, temp_dir}; +use crate::raw::{Perm, Raw, alive, error_code, read_pid, stub_server, tool_text, wait_dead}; + +pub async fn initialize_negotiates_v1_and_advertises_capabilities(t: &Target) { + let data = temp_dir("sf-data"); + let (url, _) = mock(vec![Step::Final("x")]).await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + // A client from the future gets v1 back, not an echo of its own version. + let r = agent + .ok( + "initialize", + json!({"protocolVersion": 99, "clientCapabilities": {"auth": {"terminal": true}}}), + ) + .await; + assert_eq!(r["protocolVersion"], 1); + let caps = &r["agentCapabilities"]; + assert_eq!(caps["loadSession"], true); + assert_eq!(caps["promptCapabilities"]["image"], true); + assert_eq!(caps["promptCapabilities"]["embeddedContext"], true); + let sc = &caps["sessionCapabilities"]; + for k in ["list", "resume", "close", "delete", "additionalDirectories"] { + assert!(sc.get(k).is_some(), "sessionCapabilities.{k} missing: {sc}"); + } + assert_eq!(r["agentInfo"]["name"], t.name); + assert!( + caps["auth"].get("logout").is_some(), + "auth.logout missing: {caps}" + ); + // Terminal auth is offered to clients that declare they can run it... + assert_eq!(r["authMethods"][0]["id"], "terminal-setup"); + assert_eq!(r["authMethods"][0]["args"][0], "--setup"); + agent.finish().await; + + let mut agent = Raw::spawn(&t.env(&data, &url)); + let r = agent + .ok( + "initialize", + json!({"protocolVersion": 1, "clientCapabilities": {}}), + ) + .await; + assert_eq!(r["protocolVersion"], 1); + // ...and not to clients that can't. + assert!( + r.get("authMethods") + .and_then(Value::as_array) + .is_none_or(|m| m.is_empty()), + "{r}" + ); + agent.finish().await; +} + +/// The ACP registry probes every listed agent with this exact `initialize` (copied from +/// `.github/workflows/client.py` in agentclientprotocol/registry), from an empty HOME with no +/// credentials, and rejects the agent if `authMethods` is empty. It signals terminal-auth +/// support only through `_meta`, never through `auth.terminal`. +pub async fn registry_validator_probe_sees_terminal_auth(t: &Target) { + let data = temp_dir("sf-data"); + let (url, _) = mock(vec![Step::Final("x")]).await; + let mut env = t.env(&data, &url); + env.unset("ONDE_API_KEY"); + let mut agent = Raw::spawn(&env); + let r = agent + .ok( + "initialize", + json!({ + "protocolVersion": 1, + "clientInfo": {"name": "ACP Registry Validator", "version": "1.0.0"}, + "clientCapabilities": { + "terminal": true, + "fs": {"readTextFile": true, "writeTextFile": true}, + "_meta": {"terminal_output": true, "terminal-auth": true} + } + }), + ) + .await; + let methods = r["authMethods"].as_array().expect("authMethods"); + assert!(!methods.is_empty(), "registry validation would fail: {r}"); + assert_eq!(methods[0]["type"], "terminal", "{methods:?}"); + assert_eq!(methods[0]["args"][0], "--setup"); + agent.finish().await; +} + +pub async fn auth_required_then_authenticate_reloads_config(t: &Target) { + let data = temp_dir("sf-data"); + let (url, _) = mock(vec![Step::Final("hello")]).await; + let mut env = t.env(&data, &url); + env.unset("ONDE_API_KEY"); + let mut agent = Raw::spawn(&env); + agent.initialize().await; + let cwd = temp_dir("sf-work"); + + // No key anywhere: session/new and session/prompt are refused with auth_required (-32000). + let e = agent + .call("session/new", json!({"cwd": cwd, "mcpServers": []})) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32000, "{e}"); + let e = agent + .call("session/prompt", json!({"sessionId": "x", "prompt": []})) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32000, "{e}"); + // authenticate fails while there is still no key, and rejects unknown methods. + let e = agent + .call("authenticate", json!({"methodId": "terminal-setup"})) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32000, "{e}"); + let e = agent + .call("authenticate", json!({"methodId": "nope"})) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32602, "{e}"); + + // load/resume also answer auth_required, so an editor reopening a thread shows sign-in. + let e = agent + .call( + "session/load", + json!({"sessionId": "x", "cwd": cwd, "mcpServers": []}), + ) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32000, "{e}"); + + // `--setup` stored a key in the config file; authenticate picks it up without a restart. + std::fs::write(data.join("env"), format!("ONDE_API_KEY={TEST_KEY}\n")).unwrap(); + agent + .ok("authenticate", json!({"methodId": "terminal-setup"})) + .await; + let sid = agent.new_session(&cwd, json!([])).await; + let r = agent.text_prompt(&sid, "hi").await; + assert_eq!(r["stopReason"], "end_turn"); + + // logout removes the stored key; the agent is signed out without a restart. + agent.ok("logout", json!({})).await; + assert!(!data.join("env").exists()); + let e = agent + .call("session/new", json!({"cwd": cwd, "mcpServers": []})) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32000, "{e}"); + + // Zed's flow: terminal auth runs `--setup` in another process, then retries session/new + // on this one without calling authenticate. The stored key must be picked up anyway. + std::fs::write(data.join("env"), format!("ONDE_API_KEY={TEST_KEY}\n")).unwrap(); + agent.new_session(&cwd, json!([])).await; + agent.finish().await; +} + +pub async fn sessions_survive_a_killed_process_and_replay_tool_output(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + std::fs::write(work.join("notes.txt"), "chorus in Dm").unwrap(); + let (url, _) = mock(vec![ + Step::ToolCall { + name: "read_file", + arguments: json!({"path": "notes.txt"}), + }, + Step::ToolCall { + name: "read_file", + arguments: json!({"path": "missing.txt"}), + }, + Step::Final("The chorus is in Dm."), + ]) + .await; + let env = t.env(&data, &url); + + let mut agent = Raw::spawn(&env); + agent.initialize().await; + let sid = agent.new_session(&work, json!([])).await; + let r = agent.text_prompt(&sid, "What key is the chorus in?").await; + assert_eq!(r["stopReason"], "end_turn"); + // Title is pushed as a session_info_update. + let infos = agent.updates("session_info_update"); + assert!( + infos + .iter() + .any(|u| u["title"] == "What key is the chorus in?"), + "no title update: {infos:?}" + ); + // The process dies without a graceful close. + agent.child.kill().await.unwrap(); + drop(agent); + + let mut agent = Raw::spawn(&env); + agent.initialize().await; + let list = agent.ok("session/list", json!({})).await; + let sessions = list["sessions"].as_array().unwrap(); + assert_eq!(sessions.len(), 1, "{list}"); + assert_eq!(sessions[0]["sessionId"], sid.as_str()); + assert_eq!(sessions[0]["title"], "What key is the chorus in?"); + assert!(sessions[0]["updatedAt"].as_str().unwrap().ends_with('Z')); + // cwd filter. + let other = agent.ok("session/list", json!({"cwd": "/nowhere"})).await; + assert!(other["sessions"].as_array().unwrap().is_empty()); + + agent.notes.clear(); + agent + .ok( + "session/load", + json!({"sessionId": sid, "cwd": work, "mcpServers": []}), + ) + .await; + agent.settle().await; + let user = agent.updates("user_message_chunk"); + assert_eq!(user[0]["content"]["text"], "What key is the chorus in?"); + let said: String = agent + .updates("agent_message_chunk") + .iter() + .filter_map(|u| u["content"]["text"].as_str()) + .collect(); + assert!(said.contains("The chorus is in Dm."), "{said}"); + let calls = agent.updates("tool_call"); + assert_eq!(calls.len(), 2, "{calls:?}"); + assert_eq!(calls[0]["status"], "completed"); + assert!( + tool_text(&calls[0]).contains("chorus in Dm"), + "{}", + calls[0] + ); + assert_eq!( + calls[1]["status"], "failed", + "failed call must replay as failed: {}", + calls[1] + ); + assert!( + calls[0]["locations"][0]["path"] + .as_str() + .unwrap() + .ends_with("notes.txt") + ); + // Commands are advertised after the load response. + let cmds = agent.updates("available_commands_update"); + assert!(!cmds.is_empty()); + agent.finish().await; +} + +pub async fn resume_close_delete_and_unknown_sessions(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let work2 = temp_dir("sf-work"); + let (url, stats) = mock(vec![Step::Final("ok")]).await; + let env = t.env(&data, &url); + let mut agent = Raw::spawn(&env); + agent.initialize().await; + + let sid = agent.new_session(&work, json!([])).await; + agent.text_prompt(&sid, "first").await; + + // Unknown sessions are invalid_params where nothing can be reopened. load/resume of a + // well-formed id reopen it instead (covered below); a malformed id can't name a session. + for (method, params) in [ + ( + "session/load", + json!({"sessionId": "../nope", "cwd": work, "mcpServers": []}), + ), + ( + "session/resume", + json!({"sessionId": "../nope", "cwd": work, "mcpServers": []}), + ), + ( + "session/load", + json!({"sessionId": "relative-cwd", "cwd": "work", "mcpServers": []}), + ), + ("session/new", json!({"cwd": "work", "mcpServers": []})), + ("session/close", json!({"sessionId": "nope"})), + ("session/list", json!({"cursor": "stale"})), + ( + "session/prompt", + json!({"sessionId": "nope", "prompt": [{"type": "text", "text": "x"}]}), + ), + ( + "session/set_config_option", + json!({"sessionId": "nope", "configId": "model", "value": "other-model"}), + ), + ] { + let e = agent.call(method, params).await.unwrap_err(); + assert_eq!(error_code(&e), -32602, "{method}: {e}"); + } + + // Config options: model picker lists the endpoint's models; bad ids are rejected. + let created = agent + .ok("session/new", json!({"cwd": work, "mcpServers": []})) + .await; + let opts = created["configOptions"].as_array().unwrap(); + assert_eq!(opts[0]["id"], "model"); + assert_eq!(opts[0]["category"], "model"); + assert_eq!(opts[0]["currentValue"], MODEL); + let values: Vec<&str> = opts[0]["options"] + .as_array() + .unwrap() + .iter() + .map(|o| o["value"].as_str().unwrap()) + .collect(); + assert!( + values.contains(&MODEL) && values.contains(&"other-model"), + "{values:?}" + ); + let sid2 = created["sessionId"].as_str().unwrap().to_string(); + let e = agent + .call( + "session/set_config_option", + json!({"sessionId": sid2, "configId": "model", "value": "bogus"}), + ) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32602, "{e}"); + let e = agent + .call( + "session/set_config_option", + json!({"sessionId": sid2, "configId": "temperature", "value": "x"}), + ) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32602, "{e}"); + let r = agent + .ok( + "session/set_config_option", + json!({"sessionId": sid2, "configId": "model", "value": "other-model"}), + ) + .await; + assert_eq!(r["configOptions"][0]["currentValue"], "other-model"); + agent.text_prompt(&sid2, "second").await; + let last = stats.payloads.lock().unwrap().last().cloned().unwrap(); + assert_eq!(last["model"], "other-model"); + + // Resume rebinds the cwd without replaying anything. + agent.notes.clear(); + agent + .ok( + "session/resume", + json!({"sessionId": sid, "cwd": work2, "mcpServers": []}), + ) + .await; + assert!( + agent.updates("user_message_chunk").is_empty(), + "resume must not replay" + ); + let listed = agent.ok("session/list", json!({"cwd": work2})).await; + assert_eq!(listed["sessions"].as_array().unwrap().len(), 1); + + // Close keeps the stored conversation; delete removes it. + agent.ok("session/close", json!({"sessionId": sid})).await; + let e = agent.call("session/close", json!({"sessionId": sid})).await; + assert!( + e.is_ok(), + "close of a stored-only session is a no-op, not an error" + ); + let all = agent.ok("session/list", json!({})).await; + assert_eq!(all["sessions"].as_array().unwrap().len(), 2); + agent.ok("session/delete", json!({"sessionId": sid})).await; + let all = agent.ok("session/list", json!({})).await; + assert_eq!(all["sessions"].as_array().unwrap().len(), 1); + assert!(!data.join("sessions").join(format!("{sid}.json")).exists()); + // Delete is idempotent: a second delete, or one for a session that never existed, succeeds. + agent.ok("session/delete", json!({"sessionId": sid})).await; + agent + .ok("session/delete", json!({"sessionId": "nope"})) + .await; + + // A well-formed id with no saved history (a thread from a process that never saved it) + // reopens empty under the same id, with a notice, instead of failing to launch. + agent.notes.clear(); + let r = agent + .ok( + "session/load", + json!({"sessionId": "lost-thread", "cwd": work, "mcpServers": []}), + ) + .await; + assert_eq!(r["configOptions"][0]["id"], "model"); + let notice = agent.updates("agent_message_chunk"); + assert_eq!(notice.len(), 1, "{notice:?}"); + assert!( + notice[0]["content"]["text"] + .as_str() + .unwrap() + .contains("starts fresh"), + "{notice:?}" + ); + assert!(notice[0]["messageId"].is_string()); + agent.text_prompt("lost-thread", "hello again").await; + agent.finish().await; +} + +pub async fn streaming_reports_message_ids_usage_and_inlines_file_links(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + std::fs::write( + work.join("chart.md"), + "Verse: Am F C G\nChorus: F G Em Am\nBridge: Dm E\n", + ) + .unwrap(); + let (url, stats) = mock(vec![Step::Final("Looks like A minor.")]).await; + let mut env = t.env(&data, &url); + let window = env.var("CONTEXT_WINDOW"); + env.set(&window, "1000"); + let mut agent = Raw::spawn(&env); + agent.initialize().await; + let sid = agent.new_session(&work, json!([])).await; + let chart = format!("file://{}?column=1#L2:2", work.join("chart.md").display()); + agent + .prompt( + &sid, + json!([ + {"type": "text", "text": "What key is the chorus in?"}, + {"type": "resource_link", "name": "chart.md", "uri": chart}, + ]), + ) + .await; + + // The selection link is inlined as just that line. + let payload = stats.payloads.lock().unwrap()[0].clone(); + assert!(payload["stream_options"]["include_usage"] == true); + let user = payload["messages"].as_array().unwrap().last().unwrap()["content"] + .as_str() + .unwrap() + .to_string(); + assert!(user.contains("Chorus: F G Em Am"), "{user}"); + assert!( + !user.contains("Verse:") && !user.contains("Bridge:"), + "{user}" + ); + + // Every chunk of one assistant message carries the same message id. + let chunks = agent.updates("agent_message_chunk"); + assert!(!chunks.is_empty()); + assert!( + chunks.iter().all(|c| c["messageId"].is_string()), + "{chunks:?}" + ); + // Usage from the endpoint, sized by _CONTEXT_WINDOW. + let usage = agent.updates("usage_update"); + assert_eq!(usage.len(), 1, "{usage:?}"); + assert_eq!( + (usage[0]["used"].as_u64(), usage[0]["size"].as_u64()), + (Some(150), Some(1000)) + ); + agent.finish().await; +} + +pub async fn permissions_reject_always_raw_output_and_auto_approve(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let write = |path: &'static str| Step::ToolCall { + name: "write_file", + arguments: json!({"path": path, "content": "x"}), + }; + let (url, _) = mock(vec![write("a.txt"), write("b.txt"), Step::Final("done")]).await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + // A client that supports boolean config options gets the auto-approve toggle. + let init = agent + .ok( + "initialize", + json!({"protocolVersion": 1, "clientCapabilities": {"session": {"configOptions": {"boolean": {}}}}}), + ) + .await; + assert_eq!(init["protocolVersion"], 1); + let created = agent + .ok("session/new", json!({"cwd": work, "mcpServers": []})) + .await; + agent.settle().await; + let sid = created["sessionId"].as_str().unwrap().to_string(); + let opts = created["configOptions"].as_array().unwrap(); + assert_eq!(opts[1]["id"], "auto_approve"); + assert_eq!(opts[1]["type"], "boolean"); + assert_eq!(opts[1]["currentValue"], false); + + // "Always reject" is offered, and once chosen the tool is refused without asking again. + agent.perm = Perm::RejectAlways; + agent.text_prompt(&sid, "write two files").await; + assert_eq!( + agent.permissions.len(), + 1, + "asked again after reject_always" + ); + let kinds: Vec<&str> = agent.permissions[0]["options"] + .as_array() + .unwrap() + .iter() + .map(|o| o["kind"].as_str().unwrap()) + .collect(); + assert!(kinds.contains(&"reject_always"), "{kinds:?}"); + assert!(!work.join("a.txt").exists() && !work.join("b.txt").exists()); + // Tool calls carry the tool name; updates carry the raw output the model saw. + let calls = agent.updates("tool_call"); + assert!(calls.iter().all(|c| c["name"] == "write_file"), "{calls:?}"); + let done = agent.updates("tool_call_update"); + let failed: Vec<&Value> = done.iter().filter(|u| u["status"] == "failed").collect(); + assert_eq!(failed.len(), 2, "{done:?}"); + assert_eq!(failed[0]["rawOutput"]["failed"], true); + assert!( + failed[0]["rawOutput"]["output"] + .as_str() + .unwrap() + .contains("rejected") + ); + + // Turning auto-approve on is broadcast as a config_option_update and skips the prompts. + agent.notes.clear(); + let r = agent + .ok( + "session/set_config_option", + json!({"sessionId": sid, "configId": "auto_approve", "type": "boolean", "value": true}), + ) + .await; + assert_eq!(r["configOptions"][1]["currentValue"], true); + let broadcast = agent.updates("config_option_update"); + assert_eq!(broadcast.len(), 1, "{broadcast:?}"); + let e = agent + .call( + "session/set_config_option", + json!({"sessionId": sid, "configId": "auto_approve", "value": "yes"}), + ) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32602, "{e}"); + + // A fresh session (no reject_always grant) now writes without asking. + let sid2 = agent.new_session(&work, json!([])).await; + agent + .ok( + "session/set_config_option", + json!({"sessionId": sid2, "configId": "auto_approve", "type": "boolean", "value": true}), + ) + .await; + agent.permissions.clear(); + agent.text_prompt(&sid2, "write two files").await; + assert!(agent.permissions.is_empty(), "{:?}", agent.permissions); + assert!(work.join("a.txt").exists() && work.join("b.txt").exists()); + agent.finish().await; +} + +pub async fn auto_approve_is_hidden_from_clients_without_boolean_options(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let (url, _) = mock(vec![Step::Final("x")]).await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + agent.initialize().await; + let created = agent + .ok("session/new", json!({"cwd": work, "mcpServers": []})) + .await; + agent.settle().await; + let opts = created["configOptions"].as_array().unwrap(); + assert_eq!(opts.len(), 1, "{opts:?}"); + let e = agent + .call( + "session/set_config_option", + json!({"sessionId": created["sessionId"], "configId": "auto_approve", "type": "boolean", "value": true}), + ) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32602, "{e}"); + agent.finish().await; +} + +pub async fn content_filter_is_a_refusal_and_private_flags_stay_off_the_wire(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let (url, stats) = mock(vec![ + Step::ToolCall { + name: "read_file", + arguments: json!({"path": "missing.txt"}), + }, + Step::ContentFilter, + ]) + .await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + agent.initialize().await; + let sid = agent.new_session(&work, json!([])).await; + let r = agent.text_prompt(&sid, "read it").await; + assert_eq!(r["stopReason"], "refusal"); + let payloads = stats.payloads.lock().unwrap().clone(); + let second = payloads.last().unwrap()["messages"] + .as_array() + .unwrap() + .clone(); + let tool = second.iter().find(|m| m["role"] == "tool").unwrap(); + assert!( + tool["content"].as_str().unwrap().starts_with("Error:"), + "{tool}" + ); + assert!( + tool.get("x_failed").is_none(), + "private field leaked to the model: {tool}" + ); + // The system prompt is rebuilt each turn and never stored with the history. + assert_eq!(second[0]["role"], "system"); + let stored: Value = serde_json::from_slice( + &std::fs::read(data.join("sessions").join(format!("{sid}.json"))).unwrap(), + ) + .unwrap(); + assert!( + stored["messages"] + .as_array() + .unwrap() + .iter() + .all(|m| m["role"] != "system") + ); + agent.finish().await; +} + +pub async fn client_supplied_mcp_server_is_offered_gated_and_streams_progress(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let pid_file = data.join("stub.pid"); + let (url, stats) = mock(vec![ + Step::ToolCall { + name: "mcp__stub__echo", + arguments: json!({"text": "hi"}), + }, + Step::ToolCall { + name: "mcp__stub__slow_progress", + arguments: json!({}), + }, + Step::Final("all done"), + ]) + .await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + agent.initialize().await; + let sid = agent + .new_session( + &work, + json!([stub_server(&t.mcp_stub, "stub", Some(&pid_file))]), + ) + .await; + let r = agent.text_prompt(&sid, "use the stub").await; + assert_eq!(r["stopReason"], "end_turn"); + + // Offered: the namespaced tools ride along with the built-ins. + let payload = stats.payloads.lock().unwrap()[0].clone(); + let names: Vec = payload["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| t["function"]["name"].as_str().unwrap().to_string()) + .collect(); + assert!(names.contains(&"mcp__stub__echo".to_string()), "{names:?}"); + assert_eq!(names.len(), t.base_tool_count + 5, "{names:?}"); + + // Gated: only the tool without readOnlyHint asked for permission. + assert_eq!(agent.permissions.len(), 1, "{:?}", agent.permissions); + let perm = &agent.permissions[0]; + assert_eq!(perm["toolCall"]["toolCallId"], "call_2"); + + // Callable, with the result fed back to the model. + let payloads = stats.payloads.lock().unwrap().clone(); + let tools: Vec<&Value> = payloads[1]["messages"] + .as_array() + .unwrap() + .iter() + .filter(|m| m["role"] == "tool") + .collect(); + assert_eq!(tools[0]["content"], "echo: hi"); + + // Progress: MCP notifications/progress became tool_call_update content. + let progress: Vec = agent + .updates("tool_call_update") + .iter() + .filter(|u| u["toolCallId"] == "call_2") + .map(tool_text) + .collect(); + assert!( + progress.iter().any(|t| t == "halfway (50%)"), + "{progress:?}" + ); + let done = agent + .updates("tool_call_update") + .into_iter() + .rfind(|u| u["toolCallId"] == "call_2") + .unwrap(); + assert_eq!(done["status"], "completed"); + + // Children are killed when the session closes. + let pid = read_pid(&pid_file); + assert!(alive(pid)); + agent.ok("session/close", json!({"sessionId": sid})).await; + assert!( + wait_dead(pid).await, + "stub server still running after session/close" + ); + agent.finish().await; +} + +pub async fn mcp_children_die_when_the_agent_exits(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let pid_file = data.join("stub.pid"); + let (url, _) = mock(vec![Step::Final("x")]).await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + agent.initialize().await; + agent + .new_session( + &work, + json!([stub_server(&t.mcp_stub, "stub", Some(&pid_file))]), + ) + .await; + let pid = read_pid(&pid_file); + assert!(alive(pid)); + agent.finish().await; + assert!(wait_dead(pid).await, "stub server outlived the agent"); +} + +pub async fn broken_mcp_server_is_not_fatal(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let (url, stats) = mock(vec![Step::Final("fine")]).await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + agent.initialize().await; + let sid = agent + .new_session( + &work, + json!([ + {"name": "ghost", "command": "/definitely/not/here", "args": [], "env": []}, + {"name": "http", "type": "http", "url": "http://127.0.0.1:1/mcp", "headers": []} + ]), + ) + .await; + let r = agent.text_prompt(&sid, "hello").await; + assert_eq!(r["stopReason"], "end_turn"); + let payload = stats.payloads.lock().unwrap()[0].clone(); + assert_eq!( + payload["tools"].as_array().unwrap().len(), + t.base_tool_count + ); + agent.finish().await; +} + +pub async fn cancelling_a_turn_cancels_the_mcp_call(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let (url, _) = mock(vec![ + Step::ToolCall { + name: "mcp__stub__wait_for_cancel", + arguments: json!({}), + }, + Step::Final("should not get here"), + ]) + .await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + agent.initialize().await; + let sid = agent + .new_session(&work, json!([stub_server(&t.mcp_stub, "stub", None)])) + .await; + // Fire the prompt without waiting, then cancel once the call is in flight. + let id = agent.next_id; + agent.next_id += 1; + agent + .send( + json!({"jsonrpc": "2.0", "id": id, "method": "session/prompt", + "params": {"sessionId": sid, "prompt": [{"type": "text", "text": "wait"}]}}), + ) + .await; + let mut cancelled = false; + let stop = loop { + let msg = agent.next_message().await; + if msg.get("method").is_some() { + if msg.get("id").is_some() { + agent.serve(&msg).await; + // The permission request means the tool call is about to start. + tokio::time::sleep(Duration::from_millis(300)).await; + agent + .notify("session/cancel", json!({"sessionId": sid})) + .await; + cancelled = true; + } else { + agent.notes.push(msg); + } + } else if msg["id"] == json!(id) { + break msg["result"]["stopReason"].clone(); + } + }; + assert!(cancelled, "the call should have asked for permission first"); + assert_eq!(stop, "cancelled"); + let last = agent + .updates("tool_call_update") + .into_iter() + .last() + .unwrap(); + assert_eq!(last["status"], "failed"); + agent.finish().await; +} diff --git a/crates/ed-acp-testkit/src/lib.rs b/crates/ed-acp-testkit/src/lib.rs new file mode 100644 index 0000000..c72bbd6 --- /dev/null +++ b/crates/ed-acp-testkit/src/lib.rs @@ -0,0 +1,155 @@ +//! Test support for Onde ACP agents: a mock Onde Cloud endpoint ([`mock`]), a raw JSON-RPC +//! client that drives the real agent binary over stdio ([`Raw`]), the ACP registry's validator +//! request ([`REGISTRY_VALIDATOR_INITIALIZE`]), +//! and the ACP v1 conformance suite ([`conformance`]). +//! +//! The test MCP server lives in `ed-mcp` (feature `test-server`) because it must be a binary, +//! and binaries cannot use dev-dependencies. An agent adopts the whole suite with one macro call +//! in an integration test: +//! +//! ```ignore +//! fn target() -> ed_acp_testkit::Target { +//! ed_acp_testkit::Target { +//! bin: env!("CARGO_BIN_EXE_my-agent").into(), +//! name: "my-agent", +//! env_prefix: "MY_AGENT", +//! mcp_stub: env!("CARGO_BIN_EXE_mcp_stdio_stub").into(), +//! base_tool_count: 5, +//! env: vec![], +//! } +//! } +//! ed_acp_testkit::conformance_suite!(target); +//! ``` + +pub mod conformance; +pub mod mock; +mod raw; + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +pub use mock::{MODEL, MockStats, Script, Step, TEST_KEY, mock, start_mock_llm, temp_dir}; +pub use raw::{ + Env, Perm, Raw, TIMEOUT, alive, error_code, read_pid, stub_server, tool_text, wait_dead, +}; + +/// The agent under test. +#[derive(Debug, Clone)] +pub struct Target { + /// The agent binary, usually `env!("CARGO_BIN_EXE_")`. + pub bin: PathBuf, + /// Expected `agentInfo.name`. + pub name: &'static str, + /// The agent's environment prefix: the suite sets `_DATA_DIR`, `_BASE_URL` + /// and `_MODEL`. + pub env_prefix: &'static str, + /// A binary whose `main` runs `ed_mcp::test_server::run`. + pub mcp_stub: PathBuf, + /// How many tools the agent offers with no MCP server connected. + pub base_tool_count: usize, + /// Extra environment for every run, e.g. switching built-in MCP servers off. + pub env: Vec<(String, String)>, +} + +impl Target { + /// The environment for one run: an isolated data dir, the mock endpoint at `base_url`, the + /// test key, and `PATH` set to the data dir so no stray binaries are found. + pub fn env(&self, data_dir: &Path, base_url: &str) -> Env { + let mut e = Env { + bin: self.bin.clone(), + prefix: self.env_prefix, + vars: Vec::new(), + }; + let (data, url, model) = (e.var("DATA_DIR"), e.var("BASE_URL"), e.var("MODEL")); + e.set(&data, data_dir.display().to_string()); + e.set(&url, base_url); + e.set(&model, MODEL); + e.set("ONDE_API_KEY", TEST_KEY); + e.set("PATH", data_dir.display().to_string()); + for (k, v) in &self.env { + e.set(k, v.clone()); + } + e + } +} + +/// The `initialize` params the ACP registry's validator sends, copied from +/// `agentclientprotocol/registry` `.github/workflows/client.py`. It signals terminal-auth +/// support only through `_meta`, never through `auth.terminal`. +pub fn registry_validator_initialize() -> Value { + json!({ + "protocolVersion": 1, + "clientInfo": {"name": "ACP Registry Validator", "version": "1.0.0"}, + "clientCapabilities": { + "terminal": true, + "fs": {"readTextFile": true, "writeTextFile": true}, + "_meta": {"terminal_output": true, "terminal-auth": true} + } + }) +} + +/// See [`registry_validator_initialize`]. +pub const REGISTRY_VALIDATOR_INITIALIZE: &str = r#"{"protocolVersion":1,"clientInfo":{"name":"ACP Registry Validator","version":"1.0.0"},"clientCapabilities":{"terminal":true,"fs":{"readTextFile":true,"writeTextFile":true},"_meta":{"terminal_output":true,"terminal-auth":true}}}"#; + +/// Generate one `#[tokio::test]` per conformance check, in a module named `acp_conformance`. +/// `$target` names a function, in scope where the macro is called, that returns the [`Target`]. +#[macro_export] +macro_rules! conformance_suite { + ($target:ident) => { + mod acp_conformance { + $crate::conformance_suite!(@tests $target; + initialize_negotiates_v1_and_advertises_capabilities, + registry_validator_probe_sees_terminal_auth, + auth_required_then_authenticate_reloads_config, + sessions_survive_a_killed_process_and_replay_tool_output, + resume_close_delete_and_unknown_sessions, + streaming_reports_message_ids_usage_and_inlines_file_links, + permissions_reject_always_raw_output_and_auto_approve, + auto_approve_is_hidden_from_clients_without_boolean_options, + content_filter_is_a_refusal_and_private_flags_stay_off_the_wire, + client_supplied_mcp_server_is_offered_gated_and_streams_progress, + mcp_children_die_when_the_agent_exits, + broken_mcp_server_is_not_fatal, + cancelling_a_turn_cancels_the_mcp_call, + ); + } + }; + (@tests $target:ident; $($name:ident),* $(,)?) => { + $( + #[tokio::test] + async fn $name() { + $crate::conformance::$name(&super::$target()).await; + } + )* + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_constant_and_the_value_agree() { + let parsed: Value = serde_json::from_str(REGISTRY_VALIDATOR_INITIALIZE).unwrap(); + assert_eq!(parsed, registry_validator_initialize()); + } + + #[test] + fn env_uses_the_prefix_and_target_overrides() { + let t = Target { + bin: "/bin/agent".into(), + name: "agent", + env_prefix: "MY", + mcp_stub: "/bin/stub".into(), + base_tool_count: 0, + env: vec![("MY_MODEL".into(), "custom".into())], + }; + let e = t.env(Path::new("/data"), "http://x/v1"); + let get = |k: &str| e.vars.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str()); + assert_eq!(get("MY_DATA_DIR"), Some("/data")); + assert_eq!(get("MY_BASE_URL"), Some("http://x/v1")); + assert_eq!(get("MY_MODEL"), Some("custom")); + assert_eq!(get("ONDE_API_KEY"), Some(TEST_KEY)); + } +} diff --git a/crates/ed-acp-testkit/src/mock.rs b/crates/ed-acp-testkit/src/mock.rs new file mode 100644 index 0000000..3294e40 --- /dev/null +++ b/crates/ed-acp-testkit/src/mock.rs @@ -0,0 +1,207 @@ +//! A scripted OpenAI-compatible streaming endpoint that stands in for Onde Cloud. +//! +//! It answers `GET /v1/models` with [`MODEL`] and `other-model`, records every +//! `/chat/completions` body, and replies with the next [`Step`] of its script, chosen by how many +//! tool results the request already carries. Final answers report usage when the request asks +//! for it with `stream_options.include_usage`, as Onde Cloud does. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use serde_json::{Value, json}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +/// Dummy Onde credentials; the mock asserts they arrive as the bearer token. +pub const TEST_KEY: &str = "test-app:test-secret"; +pub const MODEL: &str = "scripted-test-model"; + +#[derive(Debug, Default)] +pub struct MockStats { + pub requests: AtomicUsize, + pub saw_bearer: Mutex, + /// Every `/chat/completions` request body, in arrival order. + pub payloads: Mutex>, +} + +/// What the scripted model does on its next completion. +#[derive(Clone)] +pub enum Step { + /// Emit a tool call, then wait for the tool result. + ToolCall { + name: &'static str, + arguments: Value, + }, + /// Emit final assistant text and finish. + Final(&'static str), + /// Stop with `finish_reason: content_filter`. + ContentFilter, +} + +#[derive(Clone)] +pub struct Script(pub Vec); + +impl Script { + /// Count completed tool results to know how far into the script we are. + fn step_for(&self, messages: &[Value]) -> (usize, Step) { + let done = messages.iter().filter(|m| m["role"] == "tool").count(); + ( + done, + self.0 + .get(done) + .unwrap_or_else(|| self.0.last().expect("empty script")) + .clone(), + ) + } +} + +fn sse_chunk( + content: Option<&str>, + tool_call: Option<(&str, &str, &str)>, + finish: Option<&str>, +) -> String { + let mut delta = json!({}); + if let Some(c) = content { + delta["content"] = json!(c); + } + if let Some((id, name, args)) = tool_call { + delta["tool_calls"] = json!([{ + "index": 0, + "id": id, + "function": { "name": name, "arguments": args }, + }]); + } + let chunk = json!({ "choices": [{ "delta": delta, "finish_reason": finish }] }); + format!("data: {chunk}\n\n") +} + +async fn read_http_request(socket: &mut tokio::net::TcpStream) -> (String, String) { + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + let header_end; + let mut content_length = 0usize; + loop { + let n = socket.read(&mut tmp).await.unwrap(); + if n == 0 { + panic!("connection closed before headers complete"); + } + buf.extend_from_slice(&tmp[..n]); + if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + header_end = pos + 4; + let headers = String::from_utf8_lossy(&buf[..pos]).to_string(); + for line in headers.lines() { + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + content_length = v.trim().parse().unwrap(); + } + } + break; + } + } + while buf.len() < header_end + content_length { + let n = socket.read(&mut tmp).await.unwrap(); + if n == 0 { + break; + } + buf.extend_from_slice(&tmp[..n]); + } + let head = String::from_utf8_lossy(&buf[..header_end]).to_string(); + let body = String::from_utf8_lossy(&buf[header_end..header_end + content_length]).to_string(); + (head, body) +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +async fn respond(socket: &mut tokio::net::TcpStream, content_type: &str, body: &str) { + let resp = format!( + "HTTP/1.1 200 OK\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + socket.write_all(resp.as_bytes()).await.unwrap(); + socket.shutdown().await.unwrap(); +} + +/// Spawn the mock server; returns its base URL (e.g. `http://127.0.0.1:PORT/v1`). +pub async fn start_mock_llm(script: Script, stats: Arc) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let stats = stats.clone(); + let script = script.clone(); + tokio::spawn(async move { + let (head, body) = read_http_request(&mut socket).await; + let first = head.lines().next().unwrap_or("").to_string(); + if first.starts_with("GET /v1/models") { + let list = + json!({"object": "list", "data": [{"id": MODEL}, {"id": "other-model"}]}); + return respond(&mut socket, "application/json", &list.to_string()).await; + } + stats.requests.fetch_add(1, Ordering::SeqCst); + if head + .to_ascii_lowercase() + .contains(&format!("authorization: bearer {TEST_KEY}").to_ascii_lowercase()) + { + *stats.saw_bearer.lock().unwrap() = true; + } + assert!( + first.starts_with("POST /v1/chat/completions"), + "unexpected request: {first}" + ); + let payload: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(payload["stream"], true); + stats.payloads.lock().unwrap().push(payload.clone()); + let messages = payload["messages"].as_array().unwrap(); + let (done, step) = script.step_for(messages); + + let mut data = String::new(); + match step { + Step::ToolCall { name, arguments } => { + let id = format!("call_{}", done + 1); + data.push_str(&sse_chunk( + None, + Some((&id, name, &arguments.to_string())), + None, + )); + data.push_str(&sse_chunk(None, None, Some("tool_calls"))); + } + Step::Final(text) => { + data.push_str(&sse_chunk(Some(text), None, None)); + data.push_str(&sse_chunk(None, None, Some("stop"))); + // `stream_options.include_usage`: a final chunk with no choices. + if payload.get("stream_options").is_some() { + let usage = json!({"choices": [], "usage": { + "prompt_tokens": 120, "completion_tokens": 30, "total_tokens": 150 + }}); + data.push_str(&format!("data: {usage}\n\n")); + } + } + Step::ContentFilter => { + data.push_str(&sse_chunk(None, None, Some("content_filter"))); + } + } + data.push_str("data: [DONE]\n\n"); + respond(&mut socket, "text/event-stream", &data).await; + }); + } + }); + format!("http://127.0.0.1:{port}/v1") +} + +pub fn temp_dir(prefix: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("{prefix}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Start a mock with `steps`; returns its base URL and the recorded requests. +pub async fn mock(steps: Vec) -> (String, Arc) { + let stats = Arc::new(MockStats::default()); + let url = start_mock_llm(Script(steps), stats.clone()).await; + (url, stats) +} diff --git a/crates/ed-acp-testkit/src/raw.rs b/crates/ed-acp-testkit/src/raw.rs new file mode 100644 index 0000000..35429b6 --- /dev/null +++ b/crates/ed-acp-testkit/src/raw.rs @@ -0,0 +1,289 @@ +//! The raw JSON-RPC test client and its environment. + +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; + +/// How long to wait for any one message from the agent. +pub const TIMEOUT: Duration = Duration::from_secs(30); + +/// What the fake client does when the agent asks for permission. +#[derive(Debug, Clone, Copy)] +pub enum Perm { + Allow, + Reject, + RejectAlways, +} + +/// An ACP client speaking raw JSON-RPC to the agent's stdio, so tests see exactly what is on +/// the wire. It answers `session/request_permission` per [`Raw::perm`] and fails every other +/// agent-to-client request. +pub struct Raw { + pub child: Child, + stdin: Option, + lines: tokio::io::Lines>, + pub next_id: u64, + /// Notifications (`session/update` etc.), in arrival order. + pub notes: Vec, + /// Permission requests seen, as the full JSON-RPC `params`. + pub permissions: Vec, + pub perm: Perm, + queued: VecDeque, +} + +/// The agent's command and environment for one test. The process gets nothing else from the +/// test's environment. +#[derive(Debug, Clone)] +pub struct Env { + pub bin: PathBuf, + pub prefix: &'static str, + pub vars: Vec<(String, String)>, +} + +impl Env { + /// `_`. + pub fn var(&self, suffix: &str) -> String { + format!("{}_{suffix}", self.prefix) + } + pub fn set(&mut self, k: &str, v: impl Into) -> &mut Self { + self.vars.retain(|(n, _)| n != k); + self.vars.push((k.to_string(), v.into())); + self + } + pub fn unset(&mut self, k: &str) -> &mut Self { + self.vars.retain(|(n, _)| n != k); + self + } +} + +impl Raw { + /// Start the agent with `--acp`. + pub fn spawn(env: &Env) -> Raw { + let mut cmd = Command::new(&env.bin); + cmd.arg("--acp") + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + for (k, v) in &env.vars { + cmd.env(k, v); + } + let mut child = cmd.spawn().expect("spawn agent"); + let stdin = child.stdin.take(); + let stdout = child.stdout.take().unwrap(); + Raw { + child, + stdin, + lines: BufReader::new(stdout).lines(), + next_id: 1, + notes: Vec::new(), + permissions: Vec::new(), + perm: Perm::Allow, + queued: VecDeque::new(), + } + } + + pub async fn send(&mut self, msg: Value) { + let stdin = self.stdin.as_mut().expect("stdin closed"); + stdin + .write_all(format!("{msg}\n").as_bytes()) + .await + .unwrap(); + stdin.flush().await.unwrap(); + } + + pub async fn notify(&mut self, method: &str, params: Value) { + self.send(json!({"jsonrpc": "2.0", "method": method, "params": params})) + .await; + } + + pub async fn next_message(&mut self) -> Value { + if let Some(m) = self.queued.pop_front() { + return m; + } + let line = tokio::time::timeout(TIMEOUT, self.lines.next_line()) + .await + .expect("timed out waiting for the agent") + .unwrap() + .expect("agent closed stdout"); + serde_json::from_str(&line).unwrap_or_else(|e| panic!("non-JSON on stdout ({e}): {line}")) + } + + /// Send a request and wait for its response, serving agent→client requests meanwhile. + pub async fn call(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 1; + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})) + .await; + loop { + let msg = self.next_message().await; + if msg.get("method").is_some() { + if msg.get("id").is_some() { + self.serve(&msg).await; + } else { + self.notes.push(msg); + } + continue; + } + if msg["id"] == json!(id) { + return match msg.get("error") { + Some(e) => Err(e.clone()), + None => Ok(msg["result"].clone()), + }; + } + } + } + + pub async fn serve(&mut self, req: &Value) { + let id = req["id"].clone(); + let reply = match req["method"].as_str().unwrap() { + "session/request_permission" => { + self.permissions.push(req["params"].clone()); + match self.perm { + Perm::Allow => { + json!({"outcome": {"outcome": "selected", "optionId": "allow_once"}}) + } + Perm::Reject => { + json!({"outcome": {"outcome": "selected", "optionId": "reject_once"}}) + } + Perm::RejectAlways => { + json!({"outcome": {"outcome": "selected", "optionId": "reject_always"}}) + } + } + } + other => { + self.send(json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": format!("unsupported {other}")}})).await; + return; + } + }; + self.send(json!({"jsonrpc": "2.0", "id": id, "result": reply})) + .await; + } + + /// Read whatever the agent sends right after a response (e.g. the commands update that + /// follows `session/new`) until it has been quiet for a moment. + pub async fn settle(&mut self) { + while let Ok(line) = + tokio::time::timeout(Duration::from_millis(300), self.lines.next_line()).await + { + let Ok(Some(line)) = line else { return }; + let msg: Value = serde_json::from_str(&line).unwrap(); + if msg.get("id").is_some() && msg.get("method").is_some() { + self.serve(&msg).await; + } else { + self.notes.push(msg); + } + } + } + + pub async fn ok(&mut self, method: &str, params: Value) -> Value { + self.call(method, params) + .await + .unwrap_or_else(|e| panic!("{method} failed: {e}")) + } + + pub async fn initialize(&mut self) -> Value { + self.ok( + "initialize", + json!({"protocolVersion": 1, "clientCapabilities": {}}), + ) + .await + } + + pub async fn new_session(&mut self, cwd: &Path, mcp: Value) -> String { + let r = self + .ok("session/new", json!({"cwd": cwd, "mcpServers": mcp})) + .await; + self.settle().await; + r["sessionId"].as_str().unwrap().to_string() + } + + pub async fn prompt(&mut self, sid: &str, blocks: Value) -> Value { + self.ok( + "session/prompt", + json!({"sessionId": sid, "prompt": blocks}), + ) + .await + } + + pub async fn text_prompt(&mut self, sid: &str, text: &str) -> Value { + self.prompt(sid, json!([{"type": "text", "text": text}])) + .await + } + + /// Notifications for a session update kind, e.g. `tool_call_update`. + pub fn updates(&self, kind: &str) -> Vec { + self.notes + .iter() + .filter(|n| { + n["method"] == "session/update" && n["params"]["update"]["sessionUpdate"] == kind + }) + .map(|n| n["params"]["update"].clone()) + .collect() + } + + /// Close stdin and wait for the agent to exit on its own. + pub async fn finish(mut self) { + drop(self.stdin.take()); + let _ = tokio::time::timeout(TIMEOUT, self.child.wait()) + .await + .expect("agent did not exit"); + } +} + +/// The JSON-RPC error code. +pub fn error_code(e: &Value) -> i64 { + e["code"].as_i64().unwrap() +} + +/// The text content of a tool call or update. +pub fn tool_text(update: &Value) -> String { + update["content"] + .as_array() + .into_iter() + .flatten() + .filter_map(|c| c.pointer("/content/text").and_then(Value::as_str)) + .collect::>() + .join("\n") +} + +pub fn alive(pid: u32) -> bool { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +/// Wait up to five seconds for `pid` to exit. +pub async fn wait_dead(pid: u32) -> bool { + for _ in 0..100 { + if !alive(pid) { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false +} + +pub fn read_pid(file: &Path) -> u32 { + std::fs::read_to_string(file) + .expect("stub pid file") + .trim() + .parse() + .unwrap() +} + +/// An `mcpServers` entry for the test MCP server under `name`. +pub fn stub_server(stub: &Path, name: &str, pid_file: Option<&Path>) -> Value { + let env: Vec = pid_file + .map(|p| vec![json!({"name": "STUB_PID_FILE", "value": p})]) + .unwrap_or_default(); + json!({"name": name, "command": stub, "args": [], "env": env}) +} diff --git a/crates/ed-acp-tui/Cargo.toml b/crates/ed-acp-tui/Cargo.toml new file mode 100644 index 0000000..acaa95d --- /dev/null +++ b/crates/ed-acp-tui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ed-acp-tui" +description = "A ratatui terminal UI for any Agent Client Protocol (ACP) agent: launches the agent as a subprocess and drives one session over stdio." +keywords = ["acp", "agent", "tui", "terminal", "onde"] +categories = ["command-line-interface", "development-tools"] +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +agent-client-protocol.workspace = true +ratatui.workspace = true +crossterm.workspace = true +tokio = { workspace = true, features = ["full"] } +futures.workspace = true +anyhow.workspace = true diff --git a/crates/ed-acp-tui/LICENSE-APACHE b/crates/ed-acp-tui/LICENSE-APACHE new file mode 100644 index 0000000..bdc140a --- /dev/null +++ b/crates/ed-acp-tui/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Onde Inference + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/ed-acp-tui/LICENSE-MIT b/crates/ed-acp-tui/LICENSE-MIT new file mode 100644 index 0000000..4b6964c --- /dev/null +++ b/crates/ed-acp-tui/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Onde Inference + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/ed-acp-tui/README.md b/crates/ed-acp-tui/README.md new file mode 100644 index 0000000..7dfdea0 --- /dev/null +++ b/crates/ed-acp-tui/README.md @@ -0,0 +1,13 @@ +# ed-acp-tui + +A terminal UI for any [Agent Client Protocol](https://agentclientprotocol.com) agent. + +It is a regular ACP client: it launches the agent (usually the same binary with `--acp`) as a +subprocess and renders one session with ratatui, so it exercises the protocol path an editor +would. It serves `fs/read_text_file` and `fs/write_text_file` from local disk and answers +permission requests with `y` (allow), `a` (always), `n` (reject) or `r` (always reject). + +## Copyright + +© 2026 [Onde Inference](https://ondeinference.com). +Licensed under MIT or Apache-2.0. diff --git a/crates/ed-acp-tui/src/lib.rs b/crates/ed-acp-tui/src/lib.rs new file mode 100644 index 0000000..fd03561 --- /dev/null +++ b/crates/ed-acp-tui/src/lib.rs @@ -0,0 +1,792 @@ +//! A terminal UI for any ACP agent. It is a regular ACP client: it launches the agent (usually +//! the same binary with `--acp`) as a subprocess and renders one session with ratatui, so it +//! exercises exactly the protocol path an editor would. It serves `fs/read_text_file` and +//! `fs/write_text_file` from the local disk and answers permission requests with `y` (allow), +//! `a` (always), `n` (reject) or `r` (always reject). + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use agent_client_protocol::schema::ProtocolVersion; +use agent_client_protocol::schema::v1::{ + CancelNotification, ClientCapabilities, ContentBlock, FileSystemCapabilities, + InitializeRequest, NewSessionRequest, PermissionOptionKind, PromptRequest, ReadTextFileRequest, + ReadTextFileResponse, RequestPermissionOutcome, RequestPermissionRequest, + RequestPermissionResponse, SelectedPermissionOutcome, SessionId, SessionNotification, + SessionUpdate, StopReason, TextContent, ToolCallContent, ToolCallId, ToolCallStatus, + WriteTextFileRequest, WriteTextFileResponse, +}; +use agent_client_protocol::{AcpAgent, AcpAgentConfig, Agent, ConnectionTo}; +use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use futures::StreamExt; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Clear, Paragraph, Wrap}; +use ratatui::{DefaultTerminal, Frame}; +use tokio::sync::{mpsc, oneshot}; + +const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const MAX_TOOL_OUTPUT_LINES: usize = 8; + +enum AppEvent { + Update(SessionUpdate), + Permission( + RequestPermissionRequest, + oneshot::Sender, + ), + TurnDone(Result), +} + +/// What to launch and how to label it. +#[derive(Debug, Clone)] +pub struct TuiConfig { + /// Shown in the input box title and placeholder, e.g. `splitfire-agent`. + pub name: String, + /// The agent binary. + pub agent: PathBuf, + /// Arguments for the agent, e.g. `["--acp"]`. + pub args: Vec, + /// Extra environment for the agent, e.g. `("SPLITFIRE_SURFACE", "tui")`. + pub env: Vec<(String, String)>, + /// Shown in the status bar, e.g. `onde-kkk`. + pub model_label: String, + /// Additional workspace roots sent as `additionalDirectories`. + pub extra_roots: Vec, +} + +impl TuiConfig { + /// Launch this same executable with `--acp`, and `--yolo` when asked. + pub fn current_exe(name: impl Into, yolo: bool) -> anyhow::Result { + let mut args = vec!["--acp".to_string()]; + if yolo { + args.push("--yolo".to_string()); + } + Ok(Self { + name: name.into(), + agent: std::env::current_exe()?, + args, + env: Vec::new(), + model_label: String::new(), + extra_roots: Vec::new(), + }) + } +} + +/// Run the TUI until the user quits. The working directory is the session's `cwd`. +pub async fn run(cfg: TuiConfig) -> anyhow::Result<()> { + let mut config = AcpAgentConfig::new(&cfg.agent); + for arg in &cfg.args { + config = config.arg(arg); + } + for (k, v) in &cfg.env { + config = config.env(k, v); + } + let cwd = std::env::current_dir()?; + let model = cfg.model_label.clone(); + let name = cfg.name.clone(); + let extra_roots = cfg.extra_roots.clone(); + let (tx, rx) = mpsc::unbounded_channel(); + + agent_client_protocol::Client + .builder() + .on_receive_notification( + { + let tx = tx.clone(); + async move |n: SessionNotification, _cx| { + let _ = tx.send(AppEvent::Update(n.update)); + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_request( + { + let tx = tx.clone(); + async move |req: RequestPermissionRequest, responder, cx| { + // Wait for the user's answer off the dispatch loop so updates keep flowing. + let (answer_tx, answer_rx) = oneshot::channel(); + let _ = tx.send(AppEvent::Permission(req, answer_tx)); + cx.spawn(async move { + let outcome = answer_rx + .await + .unwrap_or(RequestPermissionOutcome::Cancelled); + responder.respond(RequestPermissionResponse::new(outcome)) + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + // ACP v1 fs/read_text_file: serve file contents from the session cwd, + // honoring the optional 1-based `line` and `limit` params. + .on_receive_request( + async move |req: ReadTextFileRequest, responder, _cx| match tokio::task::spawn_blocking( + { + let path = req.path.clone(); + move || read_text_file_blocking(&path, req.line, req.limit) + }, + ) + .await + { + Ok(Ok(r)) => responder.respond(r), + Ok(Err(e)) => responder.respond_with_error(e), + Err(e) => responder.respond_with_internal_error(e.to_string()), + }, + agent_client_protocol::on_receive_request!(), + ) + // ACP v1 fs/write_text_file: the client MUST create the file (and parent + // directories) if it doesn't exist. + .on_receive_request( + async move |req: WriteTextFileRequest, responder, _cx| { + match tokio::task::spawn_blocking({ + let path = req.path.clone(); + let content = req.content.clone(); + move || write_text_file_blocking(&path, &content) + }) + .await + { + Ok(Ok(r)) => responder.respond(r), + Ok(Err(e)) => responder.respond_with_error(e), + Err(e) => responder.respond_with_internal_error(e.to_string()), + } + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_with( + AcpAgent::new(config), + |conn: ConnectionTo| async move { + conn.send_request( + InitializeRequest::new(ProtocolVersion::V1).client_capabilities( + ClientCapabilities::new().fs(FileSystemCapabilities::new() + .read_text_file(true) + .write_text_file(true)), + ), + ) + .block_task() + .await?; + let session = conn + .send_request( + NewSessionRequest::new(cwd.clone()) + .additional_directories(extra_roots.clone()), + ) + .block_task() + .await?; + let mut app = App::new(conn, session.session_id, tx, model, name, cwd, extra_roots); + let mut terminal = ratatui::init(); + let result = app.run(&mut terminal, rx).await; + ratatui::restore(); + result.map_err(|e| { + agent_client_protocol::Error::internal_error().data(format!("{e:#}")) + }) + }, + ) + .await?; + Ok(()) +} + +/// `fs/read_text_file` handler body: reads `path`, applying the spec's optional +/// 1-based `line` and `limit` params. +fn read_text_file_blocking( + path: &Path, + line: Option, + limit: Option, +) -> agent_client_protocol::Result { + let content = std::fs::read_to_string(path).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + agent_client_protocol::Error::resource_not_found(Some(path.display().to_string())) + } else { + agent_client_protocol::Error::internal_error().data(e.to_string()) + } + })?; + let skip = line.map_or(0, |l| l.saturating_sub(1) as usize); + let take = limit.map_or(usize::MAX, |l| l as usize); + let content = content + .lines() + .skip(skip) + .take(take) + .collect::>() + .join("\n"); + Ok(ReadTextFileResponse::new(content)) +} + +/// `fs/write_text_file` handler body: the client MUST create the file if it +/// doesn't exist (spec: https://agentclientprotocol.com/protocol/v1/file-system). +fn write_text_file_blocking( + path: &Path, + content: &str, +) -> agent_client_protocol::Result { + let write = || { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, content) + }; + write().map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))?; + Ok(WriteTextFileResponse::new()) +} + +enum Entry { + User(String), + Agent(String), + Thought(String), + Tool { + id: ToolCallId, + title: String, + status: ToolCallStatus, + output: String, + }, + Info(String), + Error(String), +} + +struct App { + conn: ConnectionTo, + session_id: SessionId, + tx: mpsc::UnboundedSender, + model: String, + /// The agent's name, shown in the input box. + name: String, + /// Workspace roots, primary working directory first, then any additional roots. + roots: Vec, + entries: Vec, + input: Vec, + cursor: usize, + busy: bool, + permission: Option<( + RequestPermissionRequest, + oneshot::Sender, + )>, + /// Lines scrolled up from the bottom of the transcript; 0 follows new output. + scroll_up: usize, + tick: usize, + quit: bool, +} + +impl App { + fn new( + conn: ConnectionTo, + session_id: SessionId, + tx: mpsc::UnboundedSender, + model: String, + name: String, + cwd: PathBuf, + extra_roots: Vec, + ) -> Self { + let roots = std::iter::once(cwd.clone()) + .chain(extra_roots.into_iter().filter(|r| *r != cwd)) + .collect(); + Self { + conn, + session_id, + tx, + model, + name, + roots, + entries: vec![Entry::Info( + "Ask for a change, or type /quit to exit.".into(), + )], + input: Vec::new(), + cursor: 0, + busy: false, + permission: None, + scroll_up: 0, + tick: 0, + quit: false, + } + } + + async fn run( + &mut self, + terminal: &mut DefaultTerminal, + mut rx: mpsc::UnboundedReceiver, + ) -> anyhow::Result<()> { + let mut keys = EventStream::new(); + let mut ticker = tokio::time::interval(Duration::from_millis(80)); + while !self.quit { + terminal.draw(|f| self.draw(f))?; + tokio::select! { + ev = keys.next() => match ev { + Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => self.on_key(key), + Some(Err(e)) => return Err(e.into()), + None => break, + _ => {} + }, + Some(ev) = rx.recv() => self.on_event(ev), + _ = ticker.tick(), if self.busy => self.tick += 1, + } + } + Ok(()) + } + + fn on_event(&mut self, ev: AppEvent) { + match ev { + AppEvent::Update(update) => self.on_update(update), + AppEvent::Permission(req, answer) => self.permission = Some((req, answer)), + AppEvent::TurnDone(result) => { + self.busy = false; + match result { + Ok(StopReason::EndTurn) => {} + Ok(StopReason::Cancelled) => { + self.entries.push(Entry::Info("Cancelled.".into())) + } + Ok(other) => self + .entries + .push(Entry::Info(format!("Stopped: {other:?}"))), + Err(e) => self.entries.push(Entry::Error(e)), + } + } + } + } + + fn on_update(&mut self, update: SessionUpdate) { + match update { + SessionUpdate::AgentMessageChunk(chunk) => match self.entries.last_mut() { + Some(Entry::Agent(text)) => text.push_str(&block_text(&chunk.content)), + _ => self.entries.push(Entry::Agent(block_text(&chunk.content))), + }, + SessionUpdate::AgentThoughtChunk(chunk) => match self.entries.last_mut() { + Some(Entry::Thought(text)) => text.push_str(&block_text(&chunk.content)), + _ => self + .entries + .push(Entry::Thought(block_text(&chunk.content))), + }, + SessionUpdate::ToolCall(call) => self.entries.push(Entry::Tool { + id: call.tool_call_id, + title: call.title, + status: call.status, + output: summarize(&call.content), + }), + SessionUpdate::ToolCallUpdate(update) => { + let entry = + self.entries.iter_mut().rev().find( + |e| matches!(e, Entry::Tool { id, .. } if *id == update.tool_call_id), + ); + if let Some(Entry::Tool { + title, + status, + output, + .. + }) = entry + { + let fields = update.fields; + if let Some(s) = fields.status { + *status = s; + } + if let Some(t) = fields.title { + *title = t; + } + if let Some(c) = fields.content { + *output = summarize(&c); + } + } + } + _ => {} + } + } + + fn on_key(&mut self, key: KeyEvent) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if self.permission.is_some() { + match key.code { + KeyCode::Char('y') => self.answer_permission(Some(PermissionOptionKind::AllowOnce)), + KeyCode::Char('a') => { + self.answer_permission(Some(PermissionOptionKind::AllowAlways)) + } + KeyCode::Char('r') => { + self.answer_permission(Some(PermissionOptionKind::RejectAlways)) + } + KeyCode::Char('n') | KeyCode::Esc => { + self.answer_permission(Some(PermissionOptionKind::RejectOnce)) + } + KeyCode::Char('c') if ctrl => self.cancel_turn(), + _ => {} + } + return; + } + match key.code { + KeyCode::Char('c') if ctrl => { + if self.busy { + self.cancel_turn(); + } else if !self.input.is_empty() { + self.input.clear(); + self.cursor = 0; + } else { + self.quit = true; + } + } + KeyCode::Char('d') if ctrl && self.input.is_empty() => self.quit = true, + KeyCode::Char('a') if ctrl => self.cursor = 0, + KeyCode::Char('e') if ctrl => self.cursor = self.input.len(), + KeyCode::Char('u') if ctrl => { + self.input.drain(..self.cursor); + self.cursor = 0; + } + KeyCode::Esc if self.busy => self.cancel_turn(), + KeyCode::Enter if !self.busy => self.submit(), + KeyCode::Char(c) if !ctrl => { + self.input.insert(self.cursor, c); + self.cursor += 1; + } + KeyCode::Backspace if self.cursor > 0 => { + self.cursor -= 1; + self.input.remove(self.cursor); + } + KeyCode::Delete if self.cursor < self.input.len() => { + self.input.remove(self.cursor); + } + KeyCode::Left => self.cursor = self.cursor.saturating_sub(1), + KeyCode::Right => self.cursor = (self.cursor + 1).min(self.input.len()), + KeyCode::Home => self.cursor = 0, + KeyCode::End => self.cursor = self.input.len(), + KeyCode::PageUp => self.scroll_up += 10, + KeyCode::PageDown => self.scroll_up = self.scroll_up.saturating_sub(10), + KeyCode::Up => self.scroll_up += 1, + KeyCode::Down => self.scroll_up = self.scroll_up.saturating_sub(1), + _ => {} + } + } + + fn submit(&mut self) { + let text: String = self.input.iter().collect::().trim().to_string(); + if text.is_empty() { + return; + } + self.input.clear(); + self.cursor = 0; + if text == "/quit" || text == "/exit" { + self.quit = true; + return; + } + self.entries.push(Entry::User(text.clone())); + self.busy = true; + self.scroll_up = 0; + + let conn = self.conn.clone(); + let request = PromptRequest::new( + self.session_id.clone(), + vec![ContentBlock::Text(TextContent::new(text))], + ); + let tx = self.tx.clone(); + tokio::spawn(async move { + let result = conn.send_request(request).block_task().await; + let _ = tx.send(AppEvent::TurnDone( + result.map(|r| r.stop_reason).map_err(|e| format!("{e}")), + )); + }); + } + + fn cancel_turn(&mut self) { + if let Some((_, answer)) = self.permission.take() { + let _ = answer.send(RequestPermissionOutcome::Cancelled); + } + if let Err(e) = self + .conn + .send_notification(CancelNotification::new(self.session_id.clone())) + { + self.entries + .push(Entry::Error(format!("cancel failed: {e}"))); + } + } + + fn answer_permission(&mut self, kind: Option) { + let Some((req, answer)) = self.permission.take() else { + return; + }; + let outcome = req + .options + .iter() + .find(|o| Some(o.kind) == kind) + .map(|o| { + RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new( + o.option_id.clone(), + )) + }) + .unwrap_or(RequestPermissionOutcome::Cancelled); + let _ = answer.send(outcome); + } + + /// Show paths under a workspace root relative to it; secondary roots get a `name/` prefix. + fn short(&self, text: &str) -> String { + let mut out = text.to_string(); + for (i, root) in self.roots.iter().enumerate() { + let mut prefix = root.display().to_string(); + prefix.push('/'); + let replacement = if i == 0 { + String::new() + } else { + root_name(root) + }; + out = out.replace(&prefix, &replacement); + } + out + } + + fn draw(&self, f: &mut Frame) { + let [body, input, status] = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .areas(f.area()); + self.draw_transcript(f, body); + self.draw_input(f, input); + self.draw_status(f, status); + if let Some((req, _)) = &self.permission { + let title = req.tool_call.fields.title.clone().or_else(|| { + self.entries.iter().rev().find_map(|e| match e { + Entry::Tool { id, title, .. } if *id == req.tool_call.tool_call_id => { + Some(title.clone()) + } + _ => None, + }) + }); + draw_permission(f, &self.short(&title.unwrap_or_else(|| "Run tool".into()))); + } + } + + fn draw_transcript(&self, f: &mut Frame, area: Rect) { + let mut lines: Vec = Vec::new(); + for entry in &self.entries { + match entry { + Entry::User(text) => { + for (i, l) in text.lines().enumerate() { + let prefix = if i == 0 { "› " } else { " " }; + lines.push(Line::from(vec![prefix.cyan().bold(), l.to_string().bold()])); + } + } + Entry::Agent(text) => { + lines.extend(text.trim().lines().map(|l| Line::from(l.to_string()))); + } + Entry::Thought(text) => { + let style = Style::new() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC); + lines.extend( + text.trim() + .lines() + .map(|l| Line::styled(format!(" {l}"), style)), + ); + } + Entry::Tool { + title, + status, + output, + .. + } => { + let icon = match status { + ToolCallStatus::Completed => "✓".green(), + ToolCallStatus::Failed => "✗".red(), + _ => SPINNER[self.tick % SPINNER.len()].yellow(), + }; + lines.push(Line::from(vec![icon, " ".into(), self.short(title).bold()])); + let output = self.short(output); + let out: Vec<&str> = output + .lines() + .filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with("```")) + .collect(); + for l in out.iter().take(MAX_TOOL_OUTPUT_LINES) { + lines.push(Line::from(vec![ + " │ ".dark_gray(), + l.to_string().dark_gray(), + ])); + } + if out.len() > MAX_TOOL_OUTPUT_LINES { + let more = out.len() - MAX_TOOL_OUTPUT_LINES; + lines.push(Line::from(format!(" │ … {more} more lines").dark_gray())); + } + } + Entry::Info(text) => lines.push(Line::from(text.clone().dark_gray())), + Entry::Error(text) => lines.push(Line::from(format!("error: {text}").red())), + } + lines.push(Line::default()); + } + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + let total = paragraph.line_count(area.width); + let max_offset = total.saturating_sub(area.height as usize); + let offset = max_offset.saturating_sub(self.scroll_up); + f.render_widget( + paragraph.scroll((offset.min(u16::MAX as usize) as u16, 0)), + area, + ); + } + + fn draw_input(&self, f: &mut Frame, area: Rect) { + let border = if self.busy { + Color::DarkGray + } else { + Color::Cyan + }; + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::new().fg(border)) + .title(format!(" {} ", self.name)); + let inner = block.inner(area); + // Scroll horizontally so the cursor stays visible. + let width = inner.width.saturating_sub(1) as usize; + let start = self.cursor.saturating_sub(width); + let visible: String = self.input[start..].iter().take(width + 1).collect(); + let text = if self.input.is_empty() && !self.busy { + Line::from(format!("Ask {} to do something…", self.name).dark_gray()) + } else { + Line::from(visible) + }; + f.render_widget(Paragraph::new(text).block(block), area); + if self.permission.is_none() { + f.set_cursor_position((inner.x + (self.cursor - start) as u16, inner.y)); + } + } + + fn draw_status(&self, f: &mut Frame, area: Rect) { + let state = if self.permission.is_some() { + "waiting for approval".yellow() + } else if self.busy { + format!("{} working", SPINNER[self.tick % SPINNER.len()]).yellow() + } else { + "ready".green() + }; + let hints = if self.busy { + "Esc cancel" + } else { + "Enter send · Ctrl-C quit" + }; + let roots = self + .roots + .iter() + .map(|r| r.display().to_string()) + .collect::>() + .join(", "); + let line = Line::from(vec![ + " ".into(), + state, + " · ".dark_gray(), + self.model.clone().cyan(), + " · ".dark_gray(), + roots.dark_gray(), + " · ".dark_gray(), + hints.dark_gray(), + " · ↑↓/PgUp/PgDn scroll".dark_gray(), + ]); + f.render_widget(Paragraph::new(line), area); + } +} + +fn draw_permission(f: &mut Frame, title: &str) { + let area = f.area(); + let width = (title.chars().count() as u16 + 6).clamp(44, area.width.saturating_sub(4).max(1)); + let popup = Rect { + x: area.x + area.width.saturating_sub(width) / 2, + y: area.y + area.height.saturating_sub(7) / 2, + width: width.min(area.width), + height: 7.min(area.height), + }; + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::new().fg(Color::Yellow)) + .title(" Allow this? "); + let text = vec![ + Line::from(title.to_string().bold()), + Line::default(), + Line::from(vec![ + Span::from("[y]").green().bold(), + " allow ".into(), + Span::from("[a]").green().bold(), + " always ".into(), + Span::from("[n]").red().bold(), + " reject ".into(), + Span::from("[r]").red().bold(), + " never".into(), + ]), + ]; + f.render_widget(Clear, popup); + f.render_widget( + Paragraph::new(text).block(block).wrap(Wrap { trim: false }), + popup, + ); +} + +/// Display name for a workspace root: its directory name with a trailing slash. +fn root_name(root: &Path) -> String { + match root.file_name() { + Some(name) => format!("{}/", name.to_string_lossy()), + None => root.display().to_string(), + } +} + +fn block_text(block: &ContentBlock) -> String { + match block { + ContentBlock::Text(t) => t.text.clone(), + _ => String::new(), + } +} + +/// Short, display-friendly summary of tool output. +fn summarize(content: &[ToolCallContent]) -> String { + content + .iter() + .map(|c| match c { + ToolCallContent::Content(c) => block_text(&c.content), + ToolCallContent::Diff(d) => { + let old = d.old_text.as_deref().map_or(0, |t| t.lines().count()); + format!( + "{} (-{old} +{} lines)", + d.path.display(), + d.new_text.lines().count() + ) + } + _ => String::new(), + }) + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod fs_handler_tests { + use super::*; + + #[test] + fn read_text_file_slices_by_1_based_line_and_limit() { + let dir = std::env::temp_dir().join("ed-acp-tui-fs-test"); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("lines.txt"); + std::fs::write(&file, "one\ntwo\nthree\nfour\n").unwrap(); + + let all = read_text_file_blocking(&file, None, None).unwrap(); + assert_eq!(all.content, "one\ntwo\nthree\nfour"); + + let from_line = read_text_file_blocking(&file, Some(2), None).unwrap(); + assert_eq!(from_line.content, "two\nthree\nfour"); + + let limited = read_text_file_blocking(&file, Some(2), Some(1)).unwrap(); + assert_eq!(limited.content, "two"); + + let past_end = read_text_file_blocking(&file, Some(99), None).unwrap(); + assert_eq!(past_end.content, ""); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn read_text_file_missing_is_resource_not_found() { + let err = read_text_file_blocking(Path::new("/definitely/does/not/exist.txt"), None, None) + .unwrap_err(); + assert_eq!(err.code, agent_client_protocol::ErrorCode::ResourceNotFound); + } + + #[test] + fn write_text_file_creates_missing_file_and_parents() { + let dir = std::env::temp_dir() + .join("ed-acp-tui-fs-test-2") + .join("nested"); + let file = dir.join("created.txt"); + write_text_file_blocking(&file, "hello").unwrap(); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "hello"); + + // Overwrite existing. + write_text_file_blocking(&file, "again").unwrap(); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "again"); + + std::fs::remove_dir_all(dir.parent().unwrap()).ok(); + } +} diff --git a/crates/ed-acp/Cargo.toml b/crates/ed-acp/Cargo.toml new file mode 100644 index 0000000..f50b484 --- /dev/null +++ b/crates/ed-acp/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "ed-acp" +description = "Agent Client Protocol (ACP) v1 server for Onde agents: sessions, auth, the turn loop on Onde Cloud, tool approval, workspace tools and MCP. A product supplies a Profile." +keywords = ["acp", "agent", "llm", "onde", "mcp"] +categories = ["asynchronous", "development-tools"] +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[features] +# On-device inference with onde (`_PROVIDER=local`). Off by default: it builds +# mistral.rs and its model runtime, which a cloud-only agent doesn't need. +local = ["dep:onde"] + +[dependencies] +ed-mcp = { workspace = true, features = ["acp"] } +onde = { workspace = true, optional = true } +agent-client-protocol.workspace = true +async-trait.workspace = true +tokio = { workspace = true, features = ["full"] } +tokio-util.workspace = true +futures.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +uuid.workspace = true +directories.workspace = true +base64.workspace = true + +[dev-dependencies] +tokio = { version = "1", features = ["full"] } +# Turn the `local` feature on for this crate's own tests. +ed-acp = { path = ".", features = ["local"] } diff --git a/crates/ed-acp/LICENSE-APACHE b/crates/ed-acp/LICENSE-APACHE new file mode 100644 index 0000000..bdc140a --- /dev/null +++ b/crates/ed-acp/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Onde Inference + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/ed-acp/LICENSE-MIT b/crates/ed-acp/LICENSE-MIT new file mode 100644 index 0000000..4b6964c --- /dev/null +++ b/crates/ed-acp/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Onde Inference + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/ed-acp/README.md b/crates/ed-acp/README.md new file mode 100644 index 0000000..eb9b149 --- /dev/null +++ b/crates/ed-acp/README.md @@ -0,0 +1,17 @@ +# ed-acp + +An [Agent Client Protocol](https://agentclientprotocol.com) v1 server for Onde agents. + +It is the part of an ACP agent that is the same for every product: `initialize` and version +negotiation, auth, durable sessions, the model picker, prompt content, the turn loop against Onde +Cloud (or any OpenAI API compatible endpoint) with streaming, tool approval, workspace tools routed through the client, and MCP servers +(stdio and streamable HTTP) through [`ed-mcp`](https://crates.io/crates/ed-mcp). A product +supplies a `Profile`: its name, prompt, tools, slash commands and built-in MCP servers. + +Test an agent built on it with [`ed-acp-testkit`](https://crates.io/crates/ed-acp-testkit), and +drive it from a terminal with [`ed-acp-tui`](https://crates.io/crates/ed-acp-tui). + +## Copyright + +© 2026 [Onde Inference](https://ondeinference.com). +Licensed under MIT or Apache-2.0. diff --git a/crates/ed-acp/src/agent.rs b/crates/ed-acp/src/agent.rs new file mode 100644 index 0000000..156be25 --- /dev/null +++ b/crates/ed-acp/src/agent.rs @@ -0,0 +1,1389 @@ +//! [`AcpServer`]: the ACP v1 agent behind every Onde product. Owns sessions (in memory, backed +//! by [`SessionStore`]), the turn loop, auth, and the model and auto-approve config options. +//! Everything product-specific comes from the [`Profile`]. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use agent_client_protocol::schema::ProtocolVersion; +use agent_client_protocol::schema::v1::{ + AuthMethod, AuthMethodTerminal, AuthenticateRequest, AuthenticateResponse, + AvailableCommandsUpdate, ClientCapabilities, CloseSessionRequest, CloseSessionResponse, + ConfigOptionUpdate, ContentBlock, ContentChunk, DeleteSessionRequest, DeleteSessionResponse, + ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, + LogoutResponse, McpServer, MessageId, NewSessionRequest, NewSessionResponse, PromptRequest, + PromptResponse, ResumeSessionRequest, ResumeSessionResponse, SessionConfigOption, + SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigSelectOptions, SessionId, + SessionInfo, SessionInfoUpdate, SessionNotification, SessionUpdate, + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall, + ToolCallContent, ToolCallLocation, ToolCallStatus, ToolCallUpdateFields, UsageUpdate, +}; +use agent_client_protocol::{Client, ConnectionTo, Responder}; +use serde_json::{Value, json}; +use tokio_util::sync::CancellationToken; + +use ed_mcp::{ClientInfo, McpToolset, ToolsetConfig}; + +use crate::content as prompt; +use crate::llm::{Delta, LlmClient, LlmConfig, LlmEnv}; +use crate::store::{SessionStore, StoredSession}; +use crate::tools::{self, ToolCtx, ToolOutcome, Toolset, absolutize, normalize}; +use crate::{CommandCtx, Profile, PromptCtx, SessionCtx}; + +const MAX_TURNS: usize = 50; +/// Id of the session config option that selects the model. +pub const MODEL_CONFIG_ID: &str = "model"; +/// Id of the boolean session config option that skips permission prompts. +pub const AUTO_APPROVE_CONFIG_ID: &str = "auto_approve"; +/// Context window reported in `usage_update` when `_CONTEXT_WINDOW` is unset. +const DEFAULT_CONTEXT_WINDOW: u64 = 128_000; +/// Id of the terminal auth method advertised on initialize. +pub const AUTH_METHOD_ID: &str = "terminal-setup"; +/// Cap on the sessions returned by `session/list`. +const MAX_LISTED: usize = 200; +/// How long `run_command` may run when `_COMMAND_TIMEOUT_SECS` is unset. +const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(120); + +/// The terminal auth method: clients run ` --setup`. +pub fn auth_methods() -> Vec { + vec![AuthMethod::Terminal( + AuthMethodTerminal::new(AUTH_METHOD_ID, "Run in terminal") + .description("Interactive setup: choose a provider and store an API key") + .args(vec!["--setup".into()]), + )] +} + +/// Whether the client can run terminal auth. ACP v1 has the `auth.terminal` capability; clients +/// that predate it (including the ACP registry's validator, which probes every listed agent) +/// say so with `_meta["terminal-auth"]: true` instead. Offering no method to such a client +/// fails registry validation, so either signal counts. +pub fn supports_terminal_auth(caps: &ClientCapabilities) -> bool { + caps.auth.terminal + || caps + .meta + .as_ref() + .and_then(|m| m.get("terminal-auth")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +pub fn auth_required_error(agent: &str) -> agent_client_protocol::Error { + agent_client_protocol::Error::auth_required().data(format!( + "No API key configured. Authenticate with the terminal method (`{agent} --setup`)." + )) +} + +/// The only protocol version this agent speaks is v1. A client proposing a newer version gets +/// v1 back (it can then decide whether to continue); an older (v0) client is told v1 too, as +/// the spec requires the latest supported version when the requested one isn't supported. +pub fn negotiate_version(client: ProtocolVersion) -> ProtocolVersion { + let _ = client; + ProtocolVersion::V1 +} + +struct Session { + cwd: PathBuf, + /// Additional workspace roots (from the request's `additional_directories`). + roots: Vec, + model: String, + /// Conversation history without the system prompt (rebuilt every turn). + messages: Vec, + cancel: CancellationToken, + always_allowed: Arc>>, + always_rejected: Arc>>, + /// Approve tool calls without asking; the `auto_approve` config option. Not persisted: + /// a reopened session starts from the process default (`--yolo`). + auto_approve: Arc, + title: Option, + /// Last activity, seconds since the Unix epoch. + updated_at: u64, + mcp: Arc, + /// A turn is running; its history is checked out of `messages`. + busy: bool, +} + +impl Session { + fn fresh( + cwd: PathBuf, + roots: Vec, + model: String, + yolo: bool, + mcp: Arc, + ) -> Self { + Self::restored( + StoredSession { + cwd, + roots, + model, + title: None, + updated_at: now_secs(), + messages: Vec::new(), + }, + yolo, + mcp, + ) + } + + /// A session brought back from the store. Permission grants start fresh; MCP servers are + /// connected by the request that reopens it. + fn restored(stored: StoredSession, yolo: bool, mcp: Arc) -> Self { + Self { + cwd: stored.cwd, + roots: stored.roots, + model: stored.model, + messages: stored.messages, + cancel: CancellationToken::new(), + always_allowed: Arc::default(), + always_rejected: Arc::default(), + auto_approve: Arc::new(AtomicBool::new(yolo)), + title: stored.title, + updated_at: stored.updated_at, + mcp, + busy: false, + } + } + + fn stored(&self) -> StoredSession { + StoredSession { + cwd: self.cwd.clone(), + roots: self.roots.clone(), + model: self.model.clone(), + title: self.title.clone(), + updated_at: self.updated_at, + messages: self.messages.clone(), + } + } +} + +fn new_message_id() -> MessageId { + MessageId::new(uuid::Uuid::new_v4().to_string()) +} + +/// ACP v1 requires absolute `cwd` and `additionalDirectories`. +fn check_absolute(cwd: &Path, roots: &[PathBuf]) -> agent_client_protocol::Result<()> { + if !cwd.is_absolute() { + return Err(invalid("cwd must be an absolute path")); + } + if roots.iter().any(|r| !r.is_absolute()) { + return Err(invalid( + "additionalDirectories entries must be absolute paths", + )); + } + Ok(()) +} + +pub fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// ISO 8601 UTC timestamp without sub-second precision (no chrono dependency). +pub fn iso8601(secs: u64) -> String { + let days = secs / 86_400; + let secs_of_day = secs % 86_400; + let (h, m, s) = ( + secs_of_day / 3600, + secs_of_day % 3600 / 60, + secs_of_day % 60, + ); + // Civil-from-days algorithm (Howard Hinnant). + let z = days as i64 + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if mo <= 2 { y + 1 } else { y }; + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") +} + +/// Derive a short session title from the first user prompt. +pub fn session_title(prompt: &str) -> String { + const MAX: usize = 80; + let first = prompt.lines().next().unwrap_or("").trim(); + if first.chars().count() <= MAX { + first.to_string() + } else { + let cut: String = first.chars().take(MAX - 1).collect(); + format!("{}…", cut.trim_end()) + } +} + +fn invalid(msg: impl Into) -> agent_client_protocol::Error { + agent_client_protocol::Error::invalid_params().data(msg.into()) +} + +fn unknown_session(id: &SessionId) -> agent_client_protocol::Error { + invalid(format!("unknown session {id}")) +} + +/// A tool message; `failed` is kept in a private field that never reaches the model. +fn tool_message(id: &str, content: &str, failed: bool) -> Value { + let mut m = json!({ "role": "tool", "tool_call_id": id, "content": content }); + if failed { + m["x_failed"] = json!(true); + } + m +} + +fn wire_message(m: &Value) -> Value { + match m.as_object() { + Some(o) if o.contains_key("x_failed") => { + let mut o = o.clone(); + o.remove("x_failed"); + Value::Object(o) + } + _ => m.clone(), + } +} + +fn locations_of(paths: &[PathBuf]) -> Vec { + paths + .iter() + .map(|p| ToolCallLocation::new(absolutize(p))) + .collect() +} + +/// Text of a stored user message (`content` is a string, or an array of parts). +fn user_text(msg: &Value) -> String { + match msg.get("content") { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(parts)) => parts + .iter() + .filter_map(|p| match p.get("type").and_then(Value::as_str) { + Some("text") => p.get("text").and_then(Value::as_str).map(String::from), + Some("image_url") => Some("[image]".into()), + _ => None, + }) + .collect::>() + .join("\n\n"), + _ => String::new(), + } +} + +#[derive(Clone)] +pub struct AcpServer { + profile: Arc, + /// The profile's toolsets, built once. + toolsets: Arc>>, + env: LlmEnv, + /// Replaced when credentials change: `authenticate`, `logout`, or a key stored by + /// `--setup` in another process. + llm: Arc>, + /// Process default for each session's `auto_approve` (`--yolo` / `_YOLO`). + yolo: bool, + /// How the agent was launched: `tui` (interactive terminal UI) or `acp` (editor). + surface: &'static str, + client_caps: Arc>, + sessions: Arc>>, + /// Models offered for selection, fetched from `GET /models` on first use and dropped + /// when credentials change. + models: Arc>>>, + store: SessionStore, + data_dir: PathBuf, +} + +impl AcpServer { + pub fn new(profile: Arc, yolo: bool, surface: &'static str) -> Self { + let env = profile.info().env; + let data_dir = env.data_dir(); + Self { + toolsets: Arc::new(profile.toolsets()), + profile, + env, + llm: Arc::new(Mutex::new(LlmClient::new(LlmConfig::from_env(&env)))), + yolo, + surface, + client_caps: Arc::default(), + sessions: Arc::default(), + models: Arc::default(), + store: SessionStore::new(&data_dir), + data_dir, + } + } + + pub fn set_client_caps(&self, caps: ClientCapabilities) { + *self.client_caps.lock().unwrap() = caps; + } + + fn llm(&self) -> LlmClient { + self.llm.lock().unwrap().clone() + } + + pub fn has_api_key(&self) -> bool { + self.llm().has_api_key() + } + + pub fn model(&self) -> String { + self.llm().model().to_string() + } + + /// Re-read credentials (environment + stored `env` file) and report whether a key is + /// configured. Editors run terminal auth (` --setup`) in a separate process + /// and then retry `session/new` or `session/load` on this one without calling + /// `authenticate`, so the stored key has to be picked up here. The model list is only + /// dropped when the configuration actually changed. + pub async fn refresh_credentials(&self) -> bool { + let fresh = LlmConfig::from_env(&self.env); + let changed = fresh != *self.llm().config(); + if changed { + tracing::info!( + "credentials changed; reloading provider {}", + fresh.provider.name() + ); + *self.llm.lock().unwrap() = LlmClient::new(fresh); + *self.models.lock().await = None; + } + self.has_api_key() + } + + /// `authenticate`: the client has run ` --setup`; pick up the stored key. + pub async fn authenticate( + &self, + req: AuthenticateRequest, + ) -> agent_client_protocol::Result { + if &*req.method_id.0 != AUTH_METHOD_ID { + return Err(invalid(format!("unknown auth method {}", req.method_id.0))); + } + if self.refresh_credentials().await { + Ok(AuthenticateResponse::new()) + } else { + Err(self.auth_required()) + } + } + + /// `logout`: remove the stored key and forget it. Every file the key may be loaded from + /// goes, or the agent would still be signed in afterwards; one failed removal doesn't stop + /// the others. A key exported in the agent's own environment can't be removed from here + /// and keeps the agent signed in. + pub async fn logout(&self) -> agent_client_protocol::Result { + let failures: Vec = self + .env + .config_file_candidates() + .into_iter() + .filter_map(|path| match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => { + Some(format!("removing {}: {e}", path.display())) + } + _ => None, + }) + .collect(); + self.refresh_credentials().await; + if !failures.is_empty() { + return Err(agent_client_protocol::Error::internal_error().data(failures.join("; "))); + } + Ok(LogoutResponse::new()) + } + + fn scratch_dir(&self, id: &SessionId) -> Option { + let id = crate::store::sanitize_id(&id.0).ok()?; + Some(self.data_dir.join("scratch").join(id)) + } + + fn remove_scratch(&self, id: &SessionId) { + if let Some(dir) = self.scratch_dir(id) { + let _ = std::fs::remove_dir_all(dir); + } + } + + /// `AUTH_REQUIRED`, naming this agent's setup command. + pub fn auth_required(&self) -> agent_client_protocol::Error { + auth_required_error(self.profile.info().name) + } + + /// How a session's MCP servers are connected. + fn mcp_config(&self) -> ToolsetConfig { + let info = self.profile.info(); + ToolsetConfig::new(ClientInfo::new(info.name, info.version)) + } + + async fn connect_mcp( + &self, + servers: &[agent_client_protocol::schema::v1::McpServer], + ) -> Arc { + Arc::new( + McpToolset::connect( + self.mcp_config(), + servers, + &self.profile.builtin_mcp_servers(), + ) + .await, + ) + } + + fn system_prompt(&self, cwd: &Path, roots: &[PathBuf], mcp: &McpToolset) -> String { + self.profile.system_prompt(&PromptCtx { + surface: self.surface, + cwd, + roots, + mcp, + }) + } + + /// The selectable models. Falls back to just the configured model if the endpoint + /// can't list them; the configured model is always offered. + async fn models(&self) -> Vec { + let mut cache = self.models.lock().await; + if let Some(ids) = &*cache { + return ids.clone(); + } + let llm = self.llm(); + let default = llm.model().to_string(); + // An explicit list wins; editors show this list as the model picker. + let configured: Vec = std::env::var(self.env.var("MODELS")) + .unwrap_or_default() + .split(',') + .map(|m| m.trim().to_string()) + .filter(|m| !m.is_empty()) + .collect(); + let mut ids = if !configured.is_empty() { + configured + } else { + match llm.models().await { + Ok(models) => models.into_iter().map(|m| m.id).collect(), + Err(e) => { + tracing::warn!("listing models failed, offering only {default}: {e:#}"); + Vec::new() + } + } + }; + if !ids.contains(&default) { + ids.insert(0, default); + } + *cache = Some(ids.clone()); + ids + } + + /// Whether the client advertised `session.configOptions.boolean`, which ACP requires + /// before an agent may offer `type: "boolean"` options. + fn boolean_options_supported(&self) -> bool { + self.client_caps + .lock() + .unwrap() + .session + .as_ref() + .and_then(|s| s.config_options.as_ref()) + .is_some_and(|c| c.boolean.is_some()) + } + + async fn config_options(&self, model: &str, auto_approve: bool) -> Vec { + let options = self + .models() + .await + .into_iter() + .map(|id| SessionConfigSelectOption::new(id.clone(), id)) + .collect(); + let mut config = vec![ + SessionConfigOption::select( + MODEL_CONFIG_ID, + "Model", + model.to_string(), + SessionConfigSelectOptions::Ungrouped(options), + ) + .category(SessionConfigOptionCategory::Model), + ]; + if self.boolean_options_supported() { + config.push( + SessionConfigOption::boolean( + AUTO_APPROVE_CONFIG_ID, + "Auto-approve actions", + auto_approve, + ) + .description("Run tools that change things without asking for permission"), + ); + } + config + } + + /// The model and auto-approve setting of a live session. + fn session_settings(&self, id: &SessionId) -> Option<(String, bool)> { + self.sessions + .lock() + .unwrap() + .get(id) + .map(|s| (s.model.clone(), s.auto_approve.load(Ordering::Relaxed))) + } + + async fn session_config_options( + &self, + id: &SessionId, + ) -> agent_client_protocol::Result> { + let (model, auto_approve) = self + .session_settings(id) + .ok_or_else(|| unknown_session(id))?; + Ok(self.config_options(&model, auto_approve).await) + } + + pub async fn set_config_option( + &self, + req: SetSessionConfigOptionRequest, + connection: &ConnectionTo, + ) -> agent_client_protocol::Result { + let id = req.session_id.clone(); + self.restore(&id).await?; + match &*req.config_id.0 { + MODEL_CONFIG_ID => { + let Some(model) = req.value.as_value_id().map(|v| v.0.to_string()) else { + return Err(invalid("model must be a value id")); + }; + if !self.models().await.contains(&model) { + return Err(invalid(format!("unknown model {model}"))); + } + match self.sessions.lock().unwrap().get_mut(&id) { + Some(s) => s.model = model.clone(), + None => return Err(unknown_session(&id)), + } + self.persist(&id).await; + tracing::info!("session {id} now uses model {model}"); + } + AUTO_APPROVE_CONFIG_ID if self.boolean_options_supported() => { + let Some(on) = req.value.as_bool() else { + return Err(invalid("auto_approve must be a boolean")); + }; + match self.sessions.lock().unwrap().get(&id) { + Some(s) => s.auto_approve.store(on, Ordering::Relaxed), + None => return Err(unknown_session(&id)), + } + tracing::info!("session {id} auto-approve {on}"); + } + other => return Err(invalid(format!("unknown config option {other}"))), + } + let options = self.session_config_options(&id).await?; + // Tell every view of the session about the change, not just the requester. + connection.send_notification(SessionNotification::new( + id, + SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(options.clone())), + ))?; + Ok(SetSessionConfigOptionResponse::new(options)) + } + + /// Save a session that is not running a turn (a turn checks the history out; the + /// end-of-turn save covers it). + async fn persist(&self, id: &SessionId) { + let stored = self + .sessions + .lock() + .unwrap() + .get(id) + .filter(|s| !s.busy) + .map(Session::stored); + let Some(stored) = stored else { return }; + let store = self.store.clone(); + let sid = id.0.to_string(); + match tokio::task::spawn_blocking(move || store.save(&sid, &stored)).await { + Ok(Ok(())) => {} + Ok(Err(e)) => tracing::warn!("saving session {id} failed: {e:#}"), + Err(e) => tracing::warn!("saving session {id} failed: {e}"), + } + } + + /// Bring a stored session into memory without rebinding it (a prompt or config change + /// for a thread the client reopened without `session/load`). Errors if it is unknown. + async fn restore(&self, id: &SessionId) -> agent_client_protocol::Result<()> { + if self.sessions.lock().unwrap().contains_key(id) { + return Ok(()); + } + let stored = self + .load_stored(id) + .await? + .ok_or_else(|| unknown_session(id))?; + tracing::info!("restored session {id} from disk"); + self.sessions + .lock() + .unwrap() + .entry(id.clone()) + .or_insert_with(|| Session::restored(stored, self.yolo, self.no_mcp())); + Ok(()) + } + + async fn load_stored( + &self, + id: &SessionId, + ) -> agent_client_protocol::Result> { + let store = self.store.clone(); + let sid = id.0.to_string(); + tokio::task::spawn_blocking(move || store.load(&sid)) + .await + .map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))? + .map_err(|e| agent_client_protocol::Error::internal_error().data(format!("{e:#}"))) + } + + /// A session placeholder until its MCP servers are connected. + fn no_mcp(&self) -> Arc { + Arc::new(McpToolset::empty(self.mcp_config())) + } + + fn session_mcp(&self, id: &SessionId) -> Arc { + self.sessions + .lock() + .unwrap() + .get(id) + .map(|s| s.mcp.clone()) + .unwrap_or_else(|| self.no_mcp()) + } + + /// `AvailableCommandsUpdate` for a session, sent after the response that created it. + pub fn send_commands( + &self, + connection: &ConnectionTo, + id: &SessionId, + ) -> agent_client_protocol::Result<()> { + let mcp = self.session_mcp(id); + let commands = self.profile.slash_commands(&SessionCtx { mcp: &mcp }); + connection.send_notification(SessionNotification::new( + id.clone(), + SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)), + )) + } + + pub async fn new_session( + &self, + req: NewSessionRequest, + ) -> agent_client_protocol::Result { + check_absolute(&req.cwd, &req.additional_directories)?; + // Lexical normalization only: the client sees `cwd` again in `session/list`, so + // symlinks in it are not rewritten. + let cwd = normalize(&req.cwd); + let roots: Vec = req + .additional_directories + .iter() + .map(|r| normalize(r)) + .collect(); + let id = SessionId::new(uuid::Uuid::new_v4().to_string()); + let model = self.model(); + let mcp = self.connect_mcp(&req.mcp_servers).await; + let session = Session::fresh(cwd, roots, model.clone(), self.yolo, mcp); + self.sessions.lock().unwrap().insert(id.clone(), session); + // Saved right away so the thread survives a restart before its first prompt. + self.persist(&id).await; + let options = self.config_options(&model, self.yolo).await; + Ok(NewSessionResponse::new(id).config_options(options)) + } + + /// Live and stored sessions, optionally filtered by working directory, newest first. + pub fn list_sessions( + &self, + req: ListSessionsRequest, + ) -> agent_client_protocol::Result { + // Every session fits on one page, so no cursor is ever handed out; any cursor is stale. + if let Some(cursor) = &req.cursor { + return Err(invalid(format!("invalid cursor {cursor}"))); + } + struct Row { + id: SessionId, + cwd: PathBuf, + roots: Vec, + title: Option, + updated_at: u64, + } + let mut rows: HashMap = self + .store + .list() + .into_iter() + .map(|(id, s)| { + ( + id.clone(), + Row { + id: SessionId::new(id), + cwd: s.cwd, + roots: s.roots, + title: s.title, + updated_at: s.updated_at, + }, + ) + }) + .collect(); + // Live state wins over the (possibly older) stored copy. + for (id, s) in self.sessions.lock().unwrap().iter() { + rows.insert( + id.0.to_string(), + Row { + id: id.clone(), + cwd: s.cwd.clone(), + roots: s.roots.clone(), + title: s.title.clone(), + updated_at: s.updated_at, + }, + ); + } + // Stored cwds are normalized, so normalize the filter the same way. + let wanted = req.cwd.as_deref().map(normalize); + let mut rows: Vec = rows + .into_values() + .filter(|r| wanted.as_ref().is_none_or(|cwd| *cwd == normalize(&r.cwd))) + .collect(); + rows.sort_by(|a, b| { + b.updated_at + .cmp(&a.updated_at) + .then_with(|| a.id.0.cmp(&b.id.0)) + }); + rows.truncate(MAX_LISTED); + Ok(ListSessionsResponse::new( + rows.into_iter() + .map(|r| { + SessionInfo::new(r.id, r.cwd) + .additional_directories(r.roots) + .title(r.title) + .updated_at(iso8601(r.updated_at)) + }) + .collect(), + )) + } + + pub fn cancel(&self, session_id: &SessionId) { + if let Some(s) = self.sessions.lock().unwrap().get(session_id) { + s.cancel.cancel(); + } + } + + /// Make `id` live for `session/load` and `session/resume`: from memory, else from the + /// store; rebind cwd and roots, and reconnect MCP servers from the request. A well-formed + /// id with no saved history (a thread from a process that never saved it) reopens empty + /// under the same id, so the editor's thread opens instead of failing to launch. Returns + /// whether the history was lost. + async fn reopen( + &self, + id: &SessionId, + cwd: &Path, + roots: &[PathBuf], + mcp_servers: &[McpServer], + ) -> agent_client_protocol::Result { + check_absolute(cwd, roots)?; + if crate::store::sanitize_id(&id.0).is_err() { + return Err(unknown_session(id)); + } + let live = self.sessions.lock().unwrap().contains_key(id); + let stored = if live { + None + } else { + self.load_stored(id).await? + }; + let history_lost = !live && stored.is_none(); + let cwd = normalize(cwd); + let roots: Vec = roots.iter().map(|r| normalize(r)).collect(); + let mcp = self.connect_mcp(mcp_servers).await; + + let old_mcp = { + let mut sessions = self.sessions.lock().unwrap(); + if !sessions.contains_key(id) { + let session = match stored { + Some(stored) => Session::restored(stored, self.yolo, self.no_mcp()), + None => { + tracing::warn!("session {id} has no saved history; reopening it empty"); + Session::fresh( + cwd.clone(), + roots.clone(), + self.model(), + self.yolo, + self.no_mcp(), + ) + } + }; + sessions.insert(id.clone(), session); + } + let s = sessions.get_mut(id).ok_or_else(|| unknown_session(id))?; + s.cwd = cwd; + if !roots.is_empty() { + s.roots = roots; + } + s.updated_at = now_secs(); + std::mem::replace(&mut s.mcp, mcp) + }; + if let Some(old) = Arc::into_inner(old_mcp) { + tokio::spawn(old.shutdown()); + } + self.persist(id).await; + Ok(history_lost) + } + + /// Tell the user a reopened thread starts without its earlier history. Shown in the + /// thread only; the model doesn't see it. + fn notify_history_lost( + &self, + connection: &ConnectionTo, + id: &SessionId, + ) -> agent_client_protocol::Result<()> { + connection.send_notification(SessionNotification::new( + id.clone(), + SessionUpdate::AgentMessageChunk( + ContentChunk::new( + format!( + "{} no longer has this conversation's history, so it starts fresh \ + from here.", + self.profile.info().display_name + ) + .into(), + ) + .message_id(new_message_id()), + ), + )) + } + + /// Replay a session's conversation as `session/update` notifications (text, tool calls + /// with their status and output), then respond with config options. `session/load`. + pub async fn load_session( + &self, + req: LoadSessionRequest, + connection: &ConnectionTo, + ) -> agent_client_protocol::Result { + let id = req.session_id.clone(); + let history_lost = self + .reopen(&id, &req.cwd, &req.additional_directories, &req.mcp_servers) + .await?; + let (messages, cwd, roots) = { + let sessions = self.sessions.lock().unwrap(); + let s = sessions.get(&id).ok_or_else(|| unknown_session(&id))?; + (s.messages.clone(), s.cwd.clone(), s.roots.clone()) + }; + let notify = |update: SessionUpdate| { + connection.send_notification(SessionNotification::new(id.clone(), update)) + }; + // Tool output by call id, from the stored `tool` messages. + let results: HashMap<&str, (&str, bool)> = messages + .iter() + .filter(|m| m.get("role").and_then(Value::as_str) == Some("tool")) + .filter_map(|m| { + Some(( + m.get("tool_call_id")?.as_str()?, + ( + m.get("content").and_then(Value::as_str).unwrap_or(""), + m.get("x_failed").and_then(Value::as_bool).unwrap_or(false), + ), + )) + }) + .collect(); + for msg in &messages { + match msg.get("role").and_then(Value::as_str).unwrap_or("") { + "user" => { + let text = user_text(msg); + if !text.is_empty() { + notify(SessionUpdate::UserMessageChunk( + ContentChunk::new(text.into()).message_id(new_message_id()), + ))?; + } + } + "assistant" => { + let text = msg.get("content").and_then(Value::as_str).unwrap_or(""); + if !text.is_empty() { + notify(SessionUpdate::AgentMessageChunk( + ContentChunk::new(text.to_string().into()).message_id(new_message_id()), + ))?; + } + let calls = msg.get("tool_calls").and_then(Value::as_array); + for tc in calls.into_iter().flatten() { + let call_id = tc.get("id").and_then(Value::as_str).unwrap_or("unknown"); + let name = tc + .pointer("/function/name") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let args: Value = tc + .pointer("/function/arguments") + .and_then(Value::as_str) + .and_then(|a| serde_json::from_str(a).ok()) + .unwrap_or_else(|| json!({})); + let (title, kind, locations) = + tools::describe(&self.toolsets, &cwd, &roots, name, &args); + let mut call = ToolCall::new(call_id.to_string(), title) + .name(name.to_string()) + .kind(kind) + .locations(locations) + .raw_input(args); + match results.get(call_id) { + Some((output, failed)) => { + call = call + .status(if *failed { + ToolCallStatus::Failed + } else { + ToolCallStatus::Completed + }) + .content(vec![ToolCallContent::from(output.to_string())]) + .raw_output(json!({ "output": output, "failed": failed })); + } + // The turn was interrupted before this call produced a result. + None => call = call.status(ToolCallStatus::Failed), + } + notify(SessionUpdate::ToolCall(call))?; + } + } + // system and tool messages are not replayed on their own. + _ => {} + } + } + if history_lost { + self.notify_history_lost(connection, &id)?; + } + Ok(LoadSessionResponse::new().config_options(self.session_config_options(&id).await?)) + } + + /// Rebind a session to a new cwd / additional directories without replaying it. + pub async fn resume_session( + &self, + req: ResumeSessionRequest, + connection: &ConnectionTo, + ) -> agent_client_protocol::Result { + let id = req.session_id.clone(); + let history_lost = self + .reopen(&id, &req.cwd, &req.additional_directories, &req.mcp_servers) + .await?; + if history_lost { + self.notify_history_lost(connection, &id)?; + } + Ok(ResumeSessionResponse::new().config_options(self.session_config_options(&id).await?)) + } + + /// Cancel any in-progress work, release MCP servers and scratch audio. The stored + /// conversation stays; `session/delete` removes it. + pub fn close_session( + &self, + req: CloseSessionRequest, + ) -> agent_client_protocol::Result { + let id = req.session_id.clone(); + let removed = self.sessions.lock().unwrap().remove(&id); + match removed { + Some(s) => self.release(&id, s), + // A session from an earlier process that was never reopened has nothing to close. + None if self.store.load(&id.0).ok().flatten().is_some() => {} + None => return Err(unknown_session(&id)), + } + Ok(CloseSessionResponse::new()) + } + + /// Forget a session, in memory and on disk, with its scratch audio. Idempotent: deleting + /// an unknown session succeeds. + pub fn delete_session( + &self, + req: DeleteSessionRequest, + ) -> agent_client_protocol::Result { + let id = req.session_id.clone(); + if let Some(s) = self.sessions.lock().unwrap().remove(&id) { + self.release(&id, s); + } + // A malformed id can't name a stored session; nothing to remove. + if crate::store::sanitize_id(&id.0).is_ok() { + self.store.delete(&id.0).map_err(|e| { + agent_client_protocol::Error::internal_error().data(format!("{e:#}")) + })?; + } + self.remove_scratch(&id); + Ok(DeleteSessionResponse::new()) + } + + fn release(&self, id: &SessionId, s: Session) { + s.cancel.cancel(); + self.remove_scratch(id); + if let Some(mcp) = Arc::into_inner(s.mcp) { + tokio::spawn(mcp.shutdown()); + } + } + + /// Stop everything: used when the connection ends. + pub async fn shutdown(&self) { + let sessions: Vec = self + .sessions + .lock() + .unwrap() + .drain() + .map(|(_, s)| s) + .collect(); + for s in sessions { + s.cancel.cancel(); + if let Some(mcp) = Arc::into_inner(s.mcp) { + mcp.shutdown().await; + } + } + for server in self.profile.builtin_mcp_servers() { + server.shutdown().await; + } + } + + pub async fn prompt( + &self, + request: PromptRequest, + responder: Responder, + connection: ConnectionTo, + ) -> agent_client_protocol::Result<()> { + // A client may prompt a reopened thread without loading or resuming it first. + if let Err(e) = self.restore(&request.session_id).await { + return responder.respond_with_error(e); + } + if let Some(reply) = self.answer_slash(&request).await { + connection.send_notification(SessionNotification::new( + request.session_id.clone(), + SessionUpdate::AgentMessageChunk( + ContentChunk::new(reply.into()).message_id(new_message_id()), + ), + ))?; + return responder.respond(PromptResponse::new(StopReason::EndTurn)); + } + match self.run_turn(request, connection).await { + Ok(stop) => responder.respond(PromptResponse::new(stop)), + Err(e) if e.downcast_ref::().is_some() => { + responder.respond_with_error(invalid(e.to_string())) + } + Err(e) => responder.respond_with_error( + agent_client_protocol::Error::internal_error().data(format!("{e:#}")), + ), + } + } + + /// The profile's reply to a slash command it answers without the model, for a prompt that + /// is a single text block. The reply is not added to the conversation. + async fn answer_slash(&self, request: &PromptRequest) -> Option { + let [ContentBlock::Text(text)] = request.prompt.as_slice() else { + return None; + }; + if !text.text.trim_start().starts_with('/') { + return None; + } + let (model, _) = self.session_settings(&request.session_id)?; + let models = self.models().await; + let mcp = self.session_mcp(&request.session_id); + self.profile.answer_slash( + &text.text, + &CommandCtx { + mcp: &mcp, + model: &model, + models: &models, + }, + ) + } + + async fn run_turn( + &self, + request: PromptRequest, + connection: ConnectionTo, + ) -> anyhow::Result { + let session_id = request.session_id.clone(); + let caps = self.client_caps.lock().unwrap().clone(); + let blocks = + prompt::resolve_resource_links(&request.prompt, &caps, &connection, &session_id).await; + let prompt_text = prompt::prompt_to_text(&blocks, &self.profile.audio_hints()); + // Check the session out for the turn. + let ( + cwd, + roots, + model, + history, + cancel, + always_allowed, + always_rejected, + auto_approve, + mcp, + new_title, + ) = { + let mut sessions = self.sessions.lock().unwrap(); + let s = sessions + .get_mut(&session_id) + .ok_or_else(|| UnknownSession(session_id.to_string()))?; + if s.busy { + anyhow::bail!("a prompt is already running for session {session_id}"); + } + s.busy = true; + s.cancel = CancellationToken::new(); + s.updated_at = now_secs(); + let new_title = if s.title.is_none() && !prompt_text.trim().is_empty() { + s.title = Some(session_title(&prompt_text)); + s.title.clone() + } else { + None + }; + ( + s.cwd.clone(), + s.roots.clone(), + s.model.clone(), + std::mem::take(&mut s.messages), + s.cancel.clone(), + s.always_allowed.clone(), + s.always_rejected.clone(), + s.auto_approve.clone(), + s.mcp.clone(), + new_title, + ) + }; + if let Some(title) = new_title { + let update = SessionInfoUpdate::new() + .title(title) + .updated_at(iso8601(now_secs())); + let _ = connection.send_notification(SessionNotification::new( + session_id.clone(), + SessionUpdate::SessionInfoUpdate(update), + )); + } + + let mut messages = vec![json!({ + "role": "system", + "content": self.system_prompt(&cwd, &roots, &mcp), + })]; + messages.extend(history); + + let ctx = ToolCtx { + connection: connection.clone(), + session_id: session_id.clone(), + cwd, + roots, + caps, + cancel, + auto_approve, + always_allowed, + always_rejected, + mcp, + command_timeout: std::env::var(self.env.var("COMMAND_TIMEOUT_SECS")) + .ok() + .and_then(|v| v.parse().ok()) + .map(Duration::from_secs) + .unwrap_or(DEFAULT_COMMAND_TIMEOUT), + }; + let result = self.turn(&ctx, &model, &mut messages, &blocks).await; + + // Check the history back in, even when the turn failed. + let history = messages.split_off(1); + let stored = { + let mut sessions = self.sessions.lock().unwrap(); + sessions.get_mut(&session_id).map(|s| { + s.messages = history; + s.busy = false; + s.updated_at = now_secs(); + s.stored() + }) + }; + if let Some(stored) = stored { + let updated_at = stored.updated_at; + let store = self.store.clone(); + let id = session_id.0.to_string(); + let saved = tokio::task::spawn_blocking(move || store.save(&id, &stored)).await; + if let Ok(Err(e)) | Err(e) = saved.map_err(anyhow::Error::from) { + tracing::warn!("saving session {session_id} failed: {e:#}"); + } + let _ = connection.send_notification(SessionNotification::new( + session_id.clone(), + SessionUpdate::SessionInfoUpdate( + SessionInfoUpdate::new().updated_at(iso8601(updated_at)), + ), + )); + } + result + } + + /// Everything inside a turn after the session is checked out. + async fn turn( + &self, + ctx: &ToolCtx, + model: &str, + messages: &mut Vec, + blocks: &[ContentBlock], + ) -> anyhow::Result { + let audio_paths = match self.scratch_dir(&ctx.session_id) { + Some(dir) => prompt::save_audio_blocks(blocks, &dir).await?, + None => Vec::new(), + }; + let hints = self.profile.audio_hints(); + let slash = self.profile.expand_slash( + &prompt::prompt_to_text(blocks, &hints), + &SessionCtx { mcp: &ctx.mcp }, + ); + let content = prompt::prompt_to_content(blocks, &audio_paths, slash.as_deref(), &hints); + messages.push(json!({ "role": "user", "content": content })); + self.agent_loop(ctx, model, messages).await + } + + async fn agent_loop( + &self, + ctx: &ToolCtx, + model: &str, + messages: &mut Vec, + ) -> anyhow::Result { + let llm = self.llm(); + let tool_defs = tools::definitions(&self.toolsets, &ctx.mcp); + let notify = |update: SessionUpdate| { + ctx.connection + .send_notification(SessionNotification::new(ctx.session_id.clone(), update)) + }; + // `size` is the model's context window, which OpenAI-style endpoints don't report. + let context_window = std::env::var(self.env.var("CONTEXT_WINDOW")) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_CONTEXT_WINDOW); + + for _ in 0..MAX_TURNS { + // One message id per assistant message (and per thought), so clients can group + // the streamed chunks. + let message_id = new_message_id(); + let thought_id = new_message_id(); + let wire: Vec = messages.iter().map(wire_message).collect(); + let completion = tokio::select! { + r = llm.complete(model, &wire, &tool_defs, |delta| { + let update = match delta { + Delta::Text(t) => SessionUpdate::AgentMessageChunk( + ContentChunk::new(t.to_string().into()).message_id(message_id.clone()), + ), + Delta::Reasoning(t) => SessionUpdate::AgentThoughtChunk( + ContentChunk::new(t.to_string().into()).message_id(thought_id.clone()), + ), + }; + if let Err(e) = notify(update) { + tracing::warn!("failed to send update: {e}"); + } + }) => r?, + () = ctx.cancel.cancelled() => return Ok(StopReason::Cancelled), + }; + messages.push(completion.to_message()); + if let Some(usage) = completion.usage { + let used = usage.used(); + notify(SessionUpdate::UsageUpdate(UsageUpdate::new( + used, + context_window.max(used), + )))?; + } + + if completion.tool_calls.is_empty() { + return Ok(match completion.finish_reason.as_deref() { + Some("length") => StopReason::MaxTokens, + Some("content_filter") => StopReason::Refusal, + _ => StopReason::EndTurn, + }); + } + + for tc in &completion.tool_calls { + // Every tool call needs a matching tool message, even after cancellation. + if ctx.cancel.is_cancelled() { + messages.push(tool_message(&tc.id, "Cancelled by user.", true)); + continue; + } + let parsed: Result = serde_json::from_str(&tc.arguments); + let args = parsed.as_ref().cloned().unwrap_or_else(|_| json!({})); + let (title, kind, locations) = + tools::describe(&self.toolsets, &ctx.cwd, &ctx.roots, &tc.name, &args); + notify(SessionUpdate::ToolCall( + ToolCall::new(tc.id.clone(), title) + .name(tc.name.clone()) + .kind(kind) + .status(ToolCallStatus::InProgress) + .locations(locations) + .raw_input(args.clone()), + ))?; + + let outcome: ToolOutcome = if parsed.is_err() { + ToolOutcome::err(format!("Invalid JSON arguments: {}", tc.arguments)) + } else { + tools::execute( + ctx, + &self.toolsets, + self.profile.as_ref(), + &tc.id, + &tc.name, + args, + ) + .await + }; + + let mut fields = ToolCallUpdateFields::new() + .status(if outcome.failed { + ToolCallStatus::Failed + } else { + ToolCallStatus::Completed + }) + .content(outcome.content) + .raw_output(json!({ "output": outcome.text, "failed": outcome.failed })); + if !outcome.locations.is_empty() { + fields = fields.locations(locations_of(&outcome.locations)); + } + ctx.update(&tc.id, fields)?; + messages.push(tool_message(&tc.id, &outcome.text, outcome.failed)); + } + if ctx.cancel.is_cancelled() { + return Ok(StopReason::Cancelled); + } + } + Ok(StopReason::MaxTurnRequests) + } +} + +/// Marker error: the prompt named a session that doesn't exist. +#[derive(Debug)] +struct UnknownSession(String); + +impl std::fmt::Display for UnknownSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "unknown session {}", self.0) + } +} + +impl std::error::Error for UnknownSession {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iso8601_known_dates() { + assert_eq!(iso8601(0), "1970-01-01T00:00:00Z"); + assert_eq!(iso8601(1_700_000_000), "2023-11-14T22:13:20Z"); + assert_eq!(iso8601(951_782_400), "2000-02-29T00:00:00Z"); + } + + #[test] + fn titles_are_trimmed_and_single_line() { + assert_eq!(session_title(" hello\nworld"), "hello"); + let long = "x".repeat(200); + let t = session_title(&long); + assert_eq!(t.chars().count(), 80); + assert!(t.ends_with('…')); + } + + #[test] + fn terminal_auth_support_from_capability_or_legacy_meta() { + let caps = |v: Value| serde_json::from_value::(v).unwrap(); + assert!(!supports_terminal_auth(&caps(json!({})))); + assert!(supports_terminal_auth(&caps( + json!({"auth": {"terminal": true}}) + ))); + // The ACP registry validator's payload. + assert!(supports_terminal_auth(&caps( + json!({"terminal": true, "_meta": {"terminal_output": true, "terminal-auth": true}}) + ))); + assert!(!supports_terminal_auth(&caps( + json!({"_meta": {"terminal-auth": false}}) + ))); + } + + #[test] + fn version_negotiation_always_v1() { + assert_eq!(negotiate_version(ProtocolVersion::V1), ProtocolVersion::V1); + assert_eq!( + negotiate_version(ProtocolVersion::from(7)), + ProtocolVersion::V1 + ); + assert_eq!( + negotiate_version(ProtocolVersion::from(0)), + ProtocolVersion::V1 + ); + } + + #[test] + fn private_failure_flag_never_reaches_the_wire() { + let m = tool_message("c1", "boom", true); + assert_eq!(m["x_failed"], true); + let w = wire_message(&m); + assert!(w.get("x_failed").is_none()); + assert_eq!(w["content"], "boom"); + assert!(tool_message("c1", "ok", false).get("x_failed").is_none()); + } + + #[test] + fn user_text_handles_parts() { + let m = json!({"role": "user", "content": [ + {"type": "text", "text": "hi"}, + {"type": "image_url", "image_url": {"url": "x"}} + ]}); + assert_eq!(user_text(&m), "hi\n\n[image]"); + assert_eq!(user_text(&json!({"content": "plain"})), "plain"); + } +} diff --git a/crates/ed-acp/src/cli.rs b/crates/ed-acp/src/cli.rs new file mode 100644 index 0000000..2e2a371 --- /dev/null +++ b/crates/ed-acp/src/cli.rs @@ -0,0 +1,222 @@ +//! Command-line pieces every Onde agent shares: `--setup` (choose a provider, store and verify +//! its key), `--list-models`, flag parsing and logging. + +use std::io::IsTerminal; + +use anyhow::Context; + +use crate::AgentInfo; +use crate::llm::{ + LOCAL_DEFAULT_MODEL, LlmClient, LlmConfig, LlmEnv, OPENAI_DEFAULT_BASE_URL, + OPENAI_DEFAULT_MODEL, Provider, +}; + +/// Log to stderr, filtered by `RUST_LOG`. Stdout is reserved for ACP. +pub fn init_logging() { + let _ = tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init(); +} + +/// `1` or `true` (any case). +pub fn env_flag(name: &str) -> bool { + std::env::var(name).is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) +} + +/// Onde credentials are `app-id:app-secret`: exactly one colon, both halves non-empty. +pub fn valid_onde_key(key: &str) -> bool { + let mut parts = key.split(':'); + matches!( + (parts.next(), parts.next(), parts.next()), + (Some(id), Some(secret), None) if !id.is_empty() && !secret.is_empty() + ) +} + +/// `env` file content with `ONDE_API_KEY` set, keeping any other lines. +pub fn with_api_key(existing: &str, key: &str) -> String { + with_vars(existing, &[("ONDE_API_KEY".to_string(), key.to_string())]) +} + +/// `env` file content with each of `vars` set, replacing earlier values and keeping any +/// other lines. +pub fn with_vars(existing: &str, vars: &[(String, String)]) -> String { + let mut lines: Vec = existing + .lines() + .filter(|l| { + let l = l.trim().strip_prefix("export ").unwrap_or(l.trim()); + !vars.iter().any(|(k, _)| l.starts_with(&format!("{k}="))) + }) + .map(String::from) + .collect(); + lines.extend(vars.iter().map(|(k, v)| format!("{k}={v}"))); + lines.join("\n") + "\n" +} + +/// Interactive first-run setup (`--setup`): choose Onde Inference or an OpenAI API compatible +/// endpoint, prompt for its key, verify it, and store it in the platform config dir +/// (`config_dir()/env`, mode 0600). +pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> { + use std::io::Write; + + if !std::io::stdin().is_terminal() { + anyhow::bail!("--setup needs an interactive terminal"); + } + let prompt_line = |label: &str| -> anyhow::Result { + print!("{label}"); + std::io::stdout().flush()?; + let mut line = String::new(); + if std::io::stdin().read_line(&mut line)? == 0 { + anyhow::bail!("no input"); + } + Ok(line.trim().to_string()) + }; + + println!("{} setup\n", info.name); + println!(" 1) Onde Inference (ONDE_API_KEY)"); + println!( + " 2) OpenAI API compatible endpoint (OPENAI_BASE_URL, OPENAI_API_KEY, OPENAI_MODEL)" + ); + let local = cfg!(feature = "local"); + if local { + println!(" 3) On-device (runs here, no key; downloads a model)"); + } + let last = if local { 3 } else { 2 }; + let provider = loop { + match prompt_line(&format!("\nChoose a provider [1-{last}]: "))?.as_str() { + "" | "1" | "onde" => break Provider::Onde, + "2" | "openai" => break Provider::OpenAi, + "3" | "local" if local => break Provider::Local, + _ => println!("Please enter a number from 1 to {last}."), + } + }; + let mut vars = vec![(info.env.var("PROVIDER"), provider.name().to_string())]; + match provider { + Provider::Onde => { + println!("\nGet credentials: sign in at https://ondeinference.com/root/login,"); + println!("register an app and assign a model. Your key is \"app-id:app-secret\".\n"); + } + // A generic endpoint also needs its URL and a model that supports tool calling. + Provider::OpenAi => { + for (var, label, default) in [ + ("OPENAI_BASE_URL", "Base URL", OPENAI_DEFAULT_BASE_URL), + ("OPENAI_MODEL", "Model", OPENAI_DEFAULT_MODEL), + ] { + let value = prompt_line(&format!("{label} [{default}]: "))?; + let value = if value.is_empty() { + default.to_string() + } else { + value + }; + vars.push((var.to_string(), value)); + } + } + Provider::Local => { + println!( + "\nThe model ({}) downloads the first time the agent needs it.", + LOCAL_DEFAULT_MODEL + ); + println!("Set {} to pick another one.", info.env.var("MODEL")); + return write_env(info, &vars); + } + } + let key_var = provider.key_var(); + let key = loop { + let key = prompt_line(&format!("Paste your {key_var}: "))?; + match provider { + Provider::Onde if !valid_onde_key(&key) => { + println!("Onde credentials look like \"app-id:app-secret\" (exactly one colon).") + } + _ if key.is_empty() => println!("Key must not be empty."), + _ => break key, + } + }; + vars.push((key_var.to_string(), key)); + + // Verify before writing anything: the new values win over the environment. + let config = LlmConfig::from_lookup(&info.env, |name| { + vars.iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.clone()) + .or_else(|| std::env::var(name).ok()) + }); + print!("Verifying the key with {}… ", provider.display_name()); + std::io::stdout().flush()?; + match LlmClient::new(config).check_auth().await { + Ok(()) => println!("OK"), + Err(e) => anyhow::bail!("\nKey check failed: {e:#}\nNothing was written."), + } + + write_env(info, &vars) +} + +/// Store `vars` in the agent's `env` file (mode 0600), keeping its other lines. +fn write_env(info: &AgentInfo, vars: &[(String, String)]) -> anyhow::Result<()> { + let dir = info + .env + .config_dir() + .context("no config directory available")?; + std::fs::create_dir_all(&dir)?; + let path = dir.join("env"); + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + std::fs::write(&path, with_vars(&existing, vars))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + println!("\nWrote {}. You're all set.", path.display()); + Ok(()) +} + +/// Print the models the configured endpoint serves, one per line (`idowner`). +pub async fn list_models(info: &AgentInfo) -> anyhow::Result<()> { + let client = LlmClient::new(LlmConfig::from_env(&info.env)); + for m in client.models().await? { + match m.owned_by { + Some(owner) => println!("{}\t{}", m.id, owner), + None => println!("{}", m.id), + } + } + Ok(()) +} + +/// How the agent was launched: `tui` (interactive terminal UI) or `acp` (editor). +/// The TUI sets `_SURFACE=tui` on its subprocess; editors launch `--acp` +/// directly so the default is `acp`. +pub fn surface(env: &LlmEnv) -> &'static str { + match std::env::var(env.var("SURFACE")) { + Ok(s) if s == "tui" => "tui", + _ => "acp", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn onde_key_shape() { + assert!(valid_onde_key("app:secret")); + assert!(!valid_onde_key("secret")); + assert!(!valid_onde_key("a:b:c")); + assert!(!valid_onde_key(":secret")); + assert!(!valid_onde_key("app:")); + assert!(!valid_onde_key("")); + } + + #[test] + fn env_file_keeps_other_lines_and_replaces_key() { + let out = with_api_key("# hi\nX_MODEL=m\nexport ONDE_API_KEY=old:old\n", "new:key"); + assert_eq!(out, "# hi\nX_MODEL=m\nONDE_API_KEY=new:key\n"); + assert_eq!(with_api_key("", "a:b"), "ONDE_API_KEY=a:b\n"); + let vars = [ + ("X_PROVIDER".to_string(), "openai".to_string()), + ("OPENAI_API_KEY".to_string(), "sk".to_string()), + ]; + assert_eq!( + with_vars("X_PROVIDER=onde\nONDE_API_KEY=a:b\n", &vars), + "ONDE_API_KEY=a:b\nX_PROVIDER=openai\nOPENAI_API_KEY=sk\n" + ); + } +} diff --git a/crates/ed-acp/src/content.rs b/crates/ed-acp/src/content.rs new file mode 100644 index 0000000..28456bf --- /dev/null +++ b/crates/ed-acp/src/content.rs @@ -0,0 +1,411 @@ +//! Converting ACP prompt content into model content: text, embedded and linked files +//! (inlined, a selection as just its lines), images, and audio saved to a scratch file. + +use std::path::{Path, PathBuf}; + +use agent_client_protocol::schema::v1::{ + ClientCapabilities, ContentBlock, EmbeddedResource, EmbeddedResourceResource, + ReadTextFileRequest, ResourceLink, SessionId, TextResourceContents, +}; +use agent_client_protocol::{Client, ConnectionTo}; +use anyhow::{Context, Result}; +use base64::Engine; +use serde_json::{Value, json}; + +fn embedded_text(res: &EmbeddedResource) -> Option { + match &res.resource { + EmbeddedResourceResource::TextResourceContents(r) => { + Some(format!("\n{}\n", r.uri, r.text)) + } + // Describe binary resources rather than dropping them, so the model knows they exist. + EmbeddedResourceResource::BlobResourceContents(b) => Some(format!( + "[Attached binary resource: {} ({})]", + b.uri, + b.mime_type.as_deref().unwrap_or("unknown type") + )), + _ => None, + } +} + +/// How audio reaches the model. A product with an audio tool points the model at it here. +#[derive(Clone, Copy)] +pub struct AudioHints { + /// The text for a `file://` link to an audio file, given its path. + pub link: fn(&Path) -> String, + /// The text for an attached clip, given its MIME type and the path it was saved to. + pub attachment: fn(&str, &Path) -> String, +} + +impl Default for AudioHints { + fn default() -> Self { + Self { + link: |path| format!("[Referenced audio file: {}]", path.display()), + attachment: |mime, path| { + format!("[Attached audio ({mime}) saved at {}]", path.display()) + }, + } + } +} + +/// How a resource link is shown to the model. Audio files are never read as text; they get +/// the product's [`AudioHints::link`] text instead. +fn link_text(link: &ResourceLink, hints: &AudioHints) -> String { + match parse_file_link(&link.uri) { + Some(FileLink { path, .. }) if is_audio(&path, link.mime_type.as_deref()) => { + (hints.link)(&path) + } + _ => format!("[Referenced: {}]", link.uri), + } +} + +/// Common audio file extensions; a link to one is never read as text. +const AUDIO_EXTENSIONS: &[&str] = &[ + "wav", "wave", "mp3", "flac", "m4a", "aac", "mp4", "ogg", "oga", "opus", "aif", "aiff", "aifc", + "caf", "webm", "mkv", +]; + +fn is_audio(path: &Path, mime: Option<&str>) -> bool { + mime.is_some_and(|m| m.starts_with("audio/")) + || path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| AUDIO_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str())) +} + +/// Largest file a `file://` resource link is inlined for; bigger ones stay as references. +const MAX_LINKED_FILE_BYTES: usize = 256 * 1024; + +/// Replace `file://` resource links to text files with their embedded contents, so the model +/// sees the file. Reads through the client when it supports `fs/read_text_file` (unsaved +/// buffers), else from disk. A link to a selection (`#L10:20`) is inlined as just those +/// lines. Audio files, links that can't be read and oversized files stay as references. +pub async fn resolve_resource_links( + blocks: &[ContentBlock], + caps: &ClientCapabilities, + connection: &ConnectionTo, + session_id: &SessionId, +) -> Vec { + let mut out = Vec::with_capacity(blocks.len()); + for block in blocks { + if let ContentBlock::ResourceLink(link) = block + && let Some(FileLink { path, lines }) = parse_file_link(&link.uri) + && !is_audio(&path, link.mime_type.as_deref()) + { + let text = if caps.fs.read_text_file { + connection + .send_request(ReadTextFileRequest::new(session_id.clone(), path.clone())) + .block_task() + .await + .map(|r| r.content) + .map_err(|e| e.to_string()) + } else { + tokio::fs::read_to_string(&path) + .await + .map_err(|e| e.to_string()) + }; + let text = match lines { + Some(range) => text.map(|t| slice_lines(&t, range)), + None => text, + }; + match text { + Ok(text) if lines.is_some() && text.is_empty() => { + tracing::debug!("{} selects no lines", link.uri) + } + Ok(text) if text.len() <= MAX_LINKED_FILE_BYTES => { + out.push(ContentBlock::Resource(EmbeddedResource::new( + EmbeddedResourceResource::TextResourceContents( + TextResourceContents::new(text, link.uri.clone()) + .mime_type(link.mime_type.clone()), + ), + ))); + continue; + } + Ok(_) => tracing::debug!("{} too large to inline", link.uri), + Err(e) => tracing::debug!("could not read {}: {e}", link.uri), + } + } + out.push(block.clone()); + } + out +} + +/// A `file://` resource link: the absolute path, and the lines it selects, if any. +#[derive(Debug, PartialEq)] +struct FileLink { + path: PathBuf, + /// 1-based, inclusive. + lines: Option<(usize, usize)>, +} + +/// Parse a `file://` URI, percent-decoding the path. Editors put more than the path in it: +/// Zed links a selection as `file:///a.md?column=5#L10:20` and a symbol as +/// `file:///a.rs?symbol=main#L3:9`. The query is dropped and the fragment read as a line +/// range; a literal `?` or `#` in a file name arrives percent-encoded, so splitting first is +/// safe. +fn parse_file_link(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + let (rest, fragment) = match rest.split_once('#') { + Some((rest, fragment)) => (rest, Some(fragment)), + None => (rest, None), + }; + let rest = rest.split_once('?').map_or(rest, |(rest, _)| rest); + // Allow an authority of "" or "localhost"; reject other hosts. + let path = if rest.starts_with('/') { + rest + } else { + rest.strip_prefix("localhost")? + }; + let bytes = path.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let Ok(b) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?, 16) + { + decoded.push(b); + i += 3; + } else { + decoded.push(bytes[i]); + i += 1; + } + } + let path = PathBuf::from(String::from_utf8(decoded).ok()?); + path.is_absolute().then(|| FileLink { + path, + lines: fragment.and_then(parse_line_range), + }) +} + +/// A `#L10:20` fragment as a 1-based inclusive range. Accepts `L10:20`, `L10-20`, `L10-L20` +/// and a single line `L10`. +fn parse_line_range(fragment: &str) -> Option<(usize, usize)> { + let range = fragment.strip_prefix('L')?; + let (start, end) = range + .split_once(':') + .or_else(|| range.split_once('-')) + .unwrap_or((range, range)); + let end = end.strip_prefix('L').unwrap_or(end); + let (start, end) = (start.parse::().ok()?, end.parse::().ok()?); + (start >= 1 && end >= start).then_some((start, end)) +} + +/// Lines `start..=end` (1-based) of `text`, keeping their line endings. +fn slice_lines(text: &str, (start, end): (usize, usize)) -> String { + text.split_inclusive('\n') + .skip(start - 1) + .take(end - start + 1) + .collect() +} + +/// Flatten text-like prompt blocks into one string (used for titles and slash commands). +pub fn prompt_to_text(blocks: &[ContentBlock], hints: &AudioHints) -> String { + let mut parts = Vec::new(); + for block in blocks { + match block { + ContentBlock::Text(t) => parts.push(t.text.clone()), + ContentBlock::ResourceLink(link) => parts.push(link_text(link, hints)), + ContentBlock::Resource(res) => parts.extend(embedded_text(res)), + _ => {} + } + } + parts.join("\n\n") +} + +fn audio_extension(mime: &str) -> &'static str { + match mime.split(';').next().unwrap_or("").trim() { + "audio/wav" | "audio/x-wav" | "audio/wave" | "audio/vnd.wave" => "wav", + "audio/mpeg" | "audio/mp3" => "mp3", + "audio/flac" | "audio/x-flac" => "flac", + "audio/ogg" | "audio/vorbis" | "application/ogg" => "ogg", + "audio/mp4" | "audio/m4a" | "audio/x-m4a" | "audio/aac" => "m4a", + "audio/aiff" | "audio/x-aiff" => "aiff", + "audio/webm" | "video/webm" => "webm", + _ => "audio", + } +} + +/// Write each audio block under `dir`; returns the saved path per audio block, in order. +pub async fn save_audio_blocks(blocks: &[ContentBlock], dir: &Path) -> Result> { + let mut saved = Vec::new(); + for block in blocks { + let ContentBlock::Audio(audio) = block else { + continue; + }; + let bytes = base64::engine::general_purpose::STANDARD + .decode(audio.data.trim()) + .context("audio block is not valid base64")?; + tokio::fs::create_dir_all(dir).await?; + let path = dir.join(format!( + "audio-{}.{}", + uuid::Uuid::new_v4().simple(), + audio_extension(&audio.mime_type) + )); + tokio::fs::write(&path, bytes).await?; + saved.push(path); + } + Ok(saved) +} + +/// Convert prompt blocks to an OpenAI `content` value: a string for text only, or parts when +/// images are present. Audio is referenced by the path it was saved to, worded by +/// [`AudioHints::attachment`]. `slash` replaces the first text block (an expanded slash +/// command). +pub fn prompt_to_content( + blocks: &[ContentBlock], + audio_paths: &[PathBuf], + slash: Option<&str>, + hints: &AudioHints, +) -> Value { + let mut audio = audio_paths.iter(); + let mut parts: Vec = Vec::new(); + let mut slash = slash; + let mut has_image = false; + let text = |t: String| json!({ "type": "text", "text": t }); + for block in blocks { + match block { + ContentBlock::Text(t) => match slash.take() { + Some(expanded) => parts.push(text(expanded.to_string())), + None => parts.push(text(t.text.clone())), + }, + ContentBlock::ResourceLink(link) => parts.push(text(link_text(link, hints))), + ContentBlock::Resource(res) => { + if let Some(t) = embedded_text(res) { + parts.push(text(t)); + } + } + ContentBlock::Image(img) => { + has_image = true; + let url = format!("data:{};base64,{}", img.mime_type, img.data); + parts.push(json!({ "type": "image_url", "image_url": { "url": url } })); + } + ContentBlock::Audio(a) => match audio.next() { + Some(path) => parts.push(text((hints.attachment)(&a.mime_type, path))), + None => parts.push(text("[Attached audio could not be saved]".into())), + }, + _ => {} + } + } + if has_image { + return Value::Array(parts); + } + let joined = parts + .iter() + .filter_map(|p| p.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n\n"); + Value::String(joined) +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_client_protocol::schema::v1::{AudioContent, ImageContent, TextContent}; + + fn link(uri: &str) -> Option { + parse_file_link(uri) + } + + #[test] + fn plain_paths_decode_and_must_be_absolute() { + assert_eq!( + link("file:///a%20b/c.md"), + Some(FileLink { + path: "/a b/c.md".into(), + lines: None + }) + ); + assert_eq!( + link("file://localhost/x.md").unwrap().path, + Path::new("/x.md") + ); + assert_eq!(link("file://other-host/x.md"), None); + assert_eq!(link("https://example.com/x.md"), None); + } + + #[test] + fn zed_selection_and_symbol_links() { + let sel = link("file:///notes/song.md?column=5#L10:20").unwrap(); + assert_eq!(sel.path, Path::new("/notes/song.md")); + assert_eq!(sel.lines, Some((10, 20))); + assert_eq!( + link("file:///a.rs?symbol=main#L3:9").unwrap().lines, + Some((3, 9)) + ); + } + + #[test] + fn line_range_forms() { + assert_eq!(parse_line_range("L10:20"), Some((10, 20))); + assert_eq!(parse_line_range("L10-20"), Some((10, 20))); + assert_eq!(parse_line_range("L10-L20"), Some((10, 20))); + assert_eq!(parse_line_range("L7"), Some((7, 7))); + assert_eq!(parse_line_range("L0"), None); + assert_eq!(parse_line_range("L5:2"), None); + assert_eq!(parse_line_range("x"), None); + } + + #[test] + fn slices_inclusive_lines() { + assert_eq!(slice_lines("a\nb\nc\nd\n", (2, 3)), "b\nc\n"); + assert_eq!(slice_lines("a\nb", (2, 9)), "b"); + assert_eq!(slice_lines("a\n", (5, 6)), ""); + } + + #[test] + fn audio_links_use_the_product_hint_and_are_never_read() { + let hints = AudioHints { + link: |p| format!("analyze {}", p.display()), + ..AudioHints::default() + }; + let wav = ResourceLink::new("mix.wav", "file:///music/mix%20v2.WAV"); + assert_eq!(link_text(&wav, &hints), "analyze /music/mix v2.WAV"); + let by_mime = + ResourceLink::new("take", "file:///t/take").mime_type("audio/flac".to_string()); + assert_eq!( + link_text(&by_mime, &AudioHints::default()), + "[Referenced audio file: /t/take]" + ); + let md = ResourceLink::new("notes", "file:///t/notes.md"); + assert_eq!(link_text(&md, &hints), "[Referenced: file:///t/notes.md]"); + } + + #[tokio::test] + async fn audio_is_saved_and_referenced() { + let dir = std::env::temp_dir().join(format!("sf-prompt-{}", uuid::Uuid::new_v4())); + let data = base64::engine::general_purpose::STANDARD.encode(b"RIFFfake"); + let blocks = vec![ + ContentBlock::Text(TextContent::new("what key?")), + ContentBlock::Audio(AudioContent::new(data, "audio/wav")), + ]; + let saved = save_audio_blocks(&blocks, &dir).await.unwrap(); + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].extension().unwrap(), "wav"); + assert_eq!(std::fs::read(&saved[0]).unwrap(), b"RIFFfake"); + let content = prompt_to_content(&blocks, &saved, None, &AudioHints::default()); + let s = content.as_str().unwrap(); + assert!(s.contains("what key?") && s.contains(&saved[0].display().to_string())); + std::fs::remove_dir_all(dir).ok(); + + let bad = vec![ContentBlock::Audio(AudioContent::new("!!!", "audio/wav"))]; + assert!( + save_audio_blocks(&bad, &std::env::temp_dir()) + .await + .is_err() + ); + } + + #[test] + fn images_use_parts_and_slash_replaces_text() { + let blocks = vec![ + ContentBlock::Text(TextContent::new("/chords Am F")), + ContentBlock::Image(ImageContent::new("AAAA", "image/png")), + ]; + let v = prompt_to_content(&blocks, &[], Some("EXPANDED"), &AudioHints::default()); + let parts = v.as_array().unwrap(); + assert_eq!(parts[0]["text"], "EXPANDED"); + assert_eq!(parts[1]["type"], "image_url"); + let plain = prompt_to_content(&blocks[..1], &[], None, &AudioHints::default()); + assert_eq!(plain, json!("/chords Am F")); + } +} diff --git a/crates/ed-acp/src/lib.rs b/crates/ed-acp/src/lib.rs new file mode 100644 index 0000000..97ba25e --- /dev/null +++ b/crates/ed-acp/src/lib.rs @@ -0,0 +1,167 @@ +//! An Agent Client Protocol (ACP) v1 server for Onde agents. +//! +//! `ed-acp` is the part of an ACP agent that is the same for every product: `initialize` and +//! version negotiation, terminal auth, `authenticate` and `logout`, durable sessions (`list`, +//! `load` with full replay, `resume`, `close`, `delete`), the model picker and auto-approve +//! config options, slash-command advertisement, prompt content (embedded and linked files, +//! images, audio), the turn loop against Onde Cloud (or any OpenAI API compatible endpoint) +//! with streaming, message ids and usage, tool approval, the workspace tools routed through the +//! client's `fs/*` and `terminal/*`, and MCP servers through [`ed_mcp`]. +//! +//! A product supplies a [`Profile`]: its name, prompt, tools, slash commands and built-in MCP +//! servers. Everything in the Onde Agent Platform §5.5 conformance table is implemented here +//! once. +//! +//! ```ignore +//! let profile = Arc::new(MyProfile); +//! ed_acp::serve_stdio(profile, ServeOptions::from_env(&info)).await?; +//! ``` +//! +//! To embed the agent in a host process instead (an app sandbox that can't launch a +//! subprocess, say), serve it over an in-memory channel and run an ACP client on the other end: +//! +//! ```ignore +//! let (agent_end, client_end) = agent_client_protocol::Channel::duplex(); +//! tokio::spawn(ed_acp::serve(profile, opts, agent_end)); +//! agent_client_protocol::Client +//! .builder() +//! .connect_with(client_end, |conn| async move { +//! conn.send_request(InitializeRequest::new(ProtocolVersion::V1)).block_task().await?; +//! Ok(()) +//! }) +//! .await?; +//! ``` + +mod agent; +pub mod cli; +pub mod content; +pub mod llm; +mod local; +mod server; +pub mod store; +pub mod tools; +mod workspace; + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use agent_client_protocol::schema::v1::{AvailableCommand, PromptCapabilities}; +use async_trait::async_trait; +use ed_mcp::{Connection, McpTool, McpToolset, SharedServer}; +use serde_json::Value; + +pub use agent_client_protocol; +pub use content::AudioHints; +pub use ed_mcp; +pub use llm::{LlmConfig, LlmEnv, Provider}; +pub use server::{ServeOptions, serve, serve_stdio}; +pub use tools::{ + DescribeCtx, ToolCtx, ToolOutcome, Toolset, absolutize, function_def, normalize, resolve_in, + truncate, +}; +pub use workspace::WorkspaceTools; + +/// Who the agent is. +#[derive(Debug, Clone, Copy)] +pub struct AgentInfo { + /// Binary and protocol name, e.g. `splitfire-agent`. Reported as `agentInfo.name` and in + /// `clientInfo` to MCP servers. + pub name: &'static str, + /// Name shown to people, e.g. `SplitFire`. + pub display_name: &'static str, + pub version: &'static str, + /// Where settings and credentials come from. + pub env: LlmEnv, +} + +/// What the system prompt is built from, every turn. +pub struct PromptCtx<'a> { + /// `acp` or `tui`; see [`ServeOptions::surface`]. + pub surface: &'a str, + pub cwd: &'a Path, + pub roots: &'a [PathBuf], + /// The session's MCP tools, so the prompt can mention optional capabilities only when + /// they are connected. + pub mcp: &'a McpToolset, +} + +/// What slash commands depend on. +pub struct SessionCtx<'a> { + pub mcp: &'a McpToolset, +} + +/// What a slash command answered without the model can report. +pub struct CommandCtx<'a> { + pub mcp: &'a McpToolset, + /// The session's model. + pub model: &'a str, + /// The models offered in the model picker. + pub models: &'a [String], +} + +/// An MCP call awaiting approval. +pub struct McpCall<'a> { + pub tool: &'a McpTool, + pub args: &'a Value, + /// The server, for products that consult it (e.g. to report a download size). + pub connection: Option<&'a Connection>, +} + +/// The product-specific part of an ACP agent. +#[async_trait] +pub trait Profile: Send + Sync + 'static { + fn info(&self) -> AgentInfo; + + /// The system prompt for one turn. + fn system_prompt(&self, ctx: &PromptCtx<'_>) -> String; + + /// The tools the model may call, besides MCP tools. Include [`WorkspaceTools`] to offer + /// file and shell access. + fn toolsets(&self) -> Vec>; + + /// Commands advertised with `available_commands_update` after a session is created or + /// reopened. + fn slash_commands(&self, _ctx: &SessionCtx<'_>) -> Vec { + Vec::new() + } + + /// When `text` starts with one of this profile's commands, the instruction the model gets + /// in its place. + fn expand_slash(&self, _text: &str, _ctx: &SessionCtx<'_>) -> Option { + None + } + + /// When `text` is one of this profile's commands that needs no model (`/models`, `/setup`), + /// the reply shown in the thread. It is not added to the conversation. + fn answer_slash(&self, _text: &str, _ctx: &CommandCtx<'_>) -> Option { + None + } + + /// Process-wide MCP servers offered to every session unless the client supplies one of + /// the same name. + fn builtin_mcp_servers(&self) -> Vec<&'static SharedServer> { + Vec::new() + } + + /// Advertised in `initialize`. Audio is accepted only when this says so. + fn prompt_capabilities(&self) -> PromptCapabilities { + PromptCapabilities::new().embedded_context(true).image(true) + } + + /// How audio links and attachments are described to the model. + fn audio_hints(&self) -> AudioHints { + AudioHints::default() + } + + /// The permission prompt for a mutating MCP tool. `None` uses the default: the tool, its + /// server and its arguments. + async fn mcp_permission_preview(&self, _call: McpCall<'_>) -> Option { + None + } + + /// Files an MCP result produced, from its `structuredContent`, shown as tool-call + /// locations. Relative paths are dropped. + fn mcp_result_locations(&self, _tool: &McpTool, _structured: &Value) -> Vec { + Vec::new() + } +} diff --git a/crates/ed-acp/src/llm.rs b/crates/ed-acp/src/llm.rs new file mode 100644 index 0000000..50becd1 --- /dev/null +++ b/crates/ed-acp/src/llm.rs @@ -0,0 +1,784 @@ +//! Streaming client for OpenAI-compatible `/chat/completions` endpoints (Onde Cloud by default, +//! or any other with the `OPENAI_*` variables): text and reasoning deltas, tool-call +//! accumulation, token usage, and `GET /models`. +//! +//! Superseded by `onde-backend` once it exists (Onde Agent Platform §5.2); the agent depends +//! only on this module's public types so that swap stays local. + +use anyhow::{Context, Result, bail}; +use futures::StreamExt; +use serde::Deserialize; +use serde_json::{Value, json}; + +/// Onde Cloud's OpenAI-compatible endpoint. +pub const DEFAULT_BASE_URL: &str = "https://cloud.ondeinference.com/v1"; +/// The model used when neither the profile nor the environment names one. +pub const DEFAULT_MODEL: &str = "onde-kkk"; +/// The credential every Onde agent reads: `app-id:app-secret`. +pub const API_KEY_VAR: &str = "ONDE_API_KEY"; +/// Base URL of a generic OpenAI API compatible endpoint when `OPENAI_BASE_URL` is unset. +pub const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; +/// Model for a generic endpoint when neither `_MODEL` nor `OPENAI_MODEL` is set. +pub const OPENAI_DEFAULT_MODEL: &str = "gpt-4o-mini"; + +/// The on-device model when `_MODEL` is unset: Qwen3 1.7B on phones, where memory is +/// tight, Qwen3 4B Instruct 2507 elsewhere. Both are in onde's tool-calling catalog. +#[cfg(any(target_os = "ios", target_os = "android"))] +pub const LOCAL_DEFAULT_MODEL: &str = "bartowski/Qwen_Qwen3-1.7B-GGUF"; +/// The on-device model when `_MODEL` is unset: Qwen3 1.7B on phones, where memory is +/// tight, Qwen3 4B Instruct 2507 elsewhere. Both are in onde's tool-calling catalog. +#[cfg(not(any(target_os = "ios", target_os = "android")))] +pub const LOCAL_DEFAULT_MODEL: &str = "bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF"; + +/// Where completions come from. Both speak the OpenAI chat completions API. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Provider { + /// Onde Cloud: `ONDE_API_KEY` (`app-id:app-secret`), `_BASE_URL` or + /// `ONDE_BASE_URL` for a local or staging deployment. + Onde, + /// Any OpenAI API compatible endpoint: `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `OPENAI_MODEL`. + OpenAi, + /// On-device inference with onde: no endpoint and no key. `_MODEL` names a model + /// from onde's tool-calling catalog. Needs the `local` feature; without it every turn fails + /// with an error saying so. + Local, +} + +impl Provider { + /// `onde` or `openai`, as written to `_PROVIDER`. + pub fn name(self) -> &'static str { + match self { + Self::Onde => "onde", + Self::OpenAi => "openai", + Self::Local => "local", + } + } + + pub fn parse(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "onde" | "onde-cloud" | "ondeinference" => Some(Self::Onde), + "openai" => Some(Self::OpenAi), + "local" | "on-device" | "ondevice" => Some(Self::Local), + _ => None, + } + } + + /// The variable holding this provider's key. Empty for [`Provider::Local`], which has none. + pub fn key_var(self) -> &'static str { + match self { + Self::Onde => API_KEY_VAR, + Self::OpenAi => "OPENAI_API_KEY", + Self::Local => "", + } + } + + /// Name shown to people, e.g. in setup and errors. + pub fn display_name(self) -> &'static str { + match self { + Self::Onde => "Onde Inference", + Self::OpenAi => "the OpenAI API compatible endpoint", + Self::Local => "on-device inference", + } + } +} + +/// Where an agent's settings come from: `_BASE_URL`, `_MODEL` and +/// `_DATA_DIR` in the environment, then `//env`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LlmEnv { + /// Environment prefix, e.g. `SPLITFIRE`. + pub prefix: &'static str, + /// Directory name under the platform config dir, e.g. `splitfire-agent`. + pub dir_name: &'static str, + /// Model when `_MODEL` is unset. + pub default_model: &'static str, +} + +impl LlmEnv { + pub fn var(&self, suffix: &str) -> String { + format!("{}_{suffix}", self.prefix) + } + + /// Directory for the stored `env` file: `~/.config/` on Linux, + /// `~/Library/Application Support/` on macOS, `%APPDATA%\\` on + /// Windows. `_DATA_DIR` overrides it (tests). `None` without a home directory. + pub fn config_dir(&self) -> Option { + if let Some(dir) = std::env::var_os(self.var("DATA_DIR")).filter(|v| !v.is_empty()) { + return Some(dir.into()); + } + directories::BaseDirs::new().map(|dirs| { + #[cfg(target_os = "linux")] + let base = dirs.config_dir(); + #[cfg(not(target_os = "linux"))] + let base = dirs.data_dir(); + base.join(self.dir_name) + }) + } + + /// The stored `env` file `--setup` writes and `logout` removes. + pub fn config_file(&self) -> Option { + self.config_dir().map(|d| d.join("env")) + } + + /// Where the stored `env` file is looked for, most preferred first: [`Self::config_file`], + /// then `~/.config//env`, where agents on macOS kept it before they moved to the + /// platform directory. Only the first is used when `_DATA_DIR` is set. + pub fn config_file_candidates(&self) -> Vec { + let mut paths: Vec = self.config_file().into_iter().collect(); + let overridden = std::env::var_os(self.var("DATA_DIR")).is_some_and(|v| !v.is_empty()); + #[cfg(not(target_os = "windows"))] + if !overridden && let Some(dirs) = directories::BaseDirs::new() { + let legacy = dirs + .home_dir() + .join(".config") + .join(self.dir_name) + .join("env"); + if !paths.contains(&legacy) { + paths.push(legacy); + } + } + #[cfg(target_os = "windows")] + let _ = overridden; + paths + } + + /// Where durable state (sessions, audio scratch) lives. Same directory as the `env` file. + pub fn data_dir(&self) -> std::path::PathBuf { + self.config_dir() + .unwrap_or_else(|| std::env::temp_dir().join(self.dir_name)) + } + + fn load_file_vars(&self) -> std::collections::HashMap { + let mut vars = std::collections::HashMap::new(); + let content = self + .config_file_candidates() + .iter() + .find_map(|p| std::fs::read_to_string(p).ok()); + let Some(content) = content else { + return vars; + }; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = line.strip_prefix("export ").unwrap_or(line).trim(); + if let Some((k, v)) = line.split_once('=') { + let mut v = v.trim(); + if v.len() >= 2 + && ((v.starts_with('"') && v.ends_with('"')) + || (v.starts_with('\'') && v.ends_with('\''))) + { + v = &v[1..v.len() - 1]; + } + vars.insert(k.trim().to_string(), v.to_string()); + } + } + vars + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LlmConfig { + pub provider: Provider, + pub base_url: String, + /// Sent as the bearer token. For Onde Cloud, `app-id:app-secret`. + pub api_key: Option, + pub model: String, +} + +impl LlmConfig { + /// Settings from the environment, falling back to the stored `env` file. + /// + /// The provider is `_PROVIDER` (`onde` or `openai`) when set, else Onde Cloud + /// whenever `ONDE_API_KEY` is set, else a generic endpoint when `OPENAI_API_KEY` is, else + /// Onde Cloud. Onde Cloud reads only `ONDE_API_KEY`, and its own URL unless + /// `_BASE_URL` or `ONDE_BASE_URL` names another deployment, so stray `OPENAI_*` + /// variables can't redirect it or send it the wrong key. A generic endpoint reads + /// `OPENAI_BASE_URL`, `OPENAI_API_KEY` and `OPENAI_MODEL`. `_MODEL` picks the + /// model for either. + /// + /// Empty values are ignored: GUI launchers (e.g. Zed via launchd) often export + /// variables set to "", which must not mask the config file. + pub fn from_env(env: &LlmEnv) -> Self { + let file_vars = env.load_file_vars(); + Self::from_lookup(env, |name| { + std::env::var(name) + .ok() + .filter(|v| !v.is_empty()) + .or_else(|| file_vars.get(name).cloned()) + }) + } + + /// Build a config from an arbitrary variable lookup (tests, setup). + pub fn from_lookup(env: &LlmEnv, lookup: impl Fn(&str) -> Option) -> Self { + let get = |name: &str| lookup(name).filter(|v| !v.is_empty()); + let provider = match get(&env.var("PROVIDER")) { + Some(name) => Provider::parse(&name).unwrap_or_else(|| { + tracing::warn!("unknown {} {name:?}; using onde", env.var("PROVIDER")); + Provider::Onde + }), + None if get(API_KEY_VAR).is_some() => Provider::Onde, + None if get("OPENAI_API_KEY").is_some() => Provider::OpenAi, + None => Provider::Onde, + }; + let (base_url, model) = match provider { + Provider::Onde => ( + get(&env.var("BASE_URL")) + .or_else(|| get("ONDE_BASE_URL")) + .unwrap_or_else(|| DEFAULT_BASE_URL.into()), + get(&env.var("MODEL")).unwrap_or_else(|| env.default_model.into()), + ), + Provider::OpenAi => ( + get("OPENAI_BASE_URL").unwrap_or_else(|| OPENAI_DEFAULT_BASE_URL.into()), + get(&env.var("MODEL")) + .or_else(|| get("OPENAI_MODEL")) + .unwrap_or_else(|| OPENAI_DEFAULT_MODEL.into()), + ), + Provider::Local => ( + String::new(), + get(&env.var("MODEL")).unwrap_or_else(|| LOCAL_DEFAULT_MODEL.into()), + ), + }; + Self { + provider, + base_url: base_url.trim_end_matches('/').to_string(), + // On-device inference has no key, and `get("")` must not find one in an env file. + api_key: match provider { + Provider::Local => None, + _ => get(provider.key_var()), + }, + model, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ENV: LlmEnv = LlmEnv { + prefix: "SPLITFIRE", + dir_name: "splitfire-agent", + default_model: DEFAULT_MODEL, + }; + + fn config(vars: &[(&str, &str)]) -> LlmConfig { + LlmConfig::from_lookup(&ENV, |name| { + vars.iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| v.to_string()) + }) + } + + #[test] + fn defaults_point_at_onde_cloud() { + let c = config(&[("ONDE_API_KEY", "app:secret")]); + assert_eq!(c.base_url, "https://cloud.ondeinference.com/v1"); + assert_eq!(c.api_key.as_deref(), Some("app:secret")); + assert_eq!(c.model, "onde-kkk"); + assert_eq!(config(&[]).api_key, None); + assert_eq!(config(&[("ONDE_API_KEY", "")]).api_key, None); + } + + #[test] + fn local_needs_no_key() { + let c = config(&[ + ("SPLITFIRE_PROVIDER", "local"), + ("ONDE_API_KEY", "app:secret"), + ]); + assert_eq!(c.provider, Provider::Local); + assert_eq!(c.model, LOCAL_DEFAULT_MODEL); + // A stored Onde key is not this provider's. + assert_eq!(c.api_key, None); + assert!(LlmClient::new(c).has_api_key()); + assert_eq!(Provider::parse("on-device"), Some(Provider::Local)); + let picked = config(&[ + ("SPLITFIRE_PROVIDER", "local"), + ("SPLITFIRE_MODEL", "bartowski/Qwen_Qwen3-8B-GGUF"), + ]); + assert_eq!(picked.model, "bartowski/Qwen_Qwen3-8B-GGUF"); + } + + #[test] + fn base_url_and_model_overrides_apply() { + let c = config(&[ + ("ONDE_API_KEY", "app:secret"), + ("SPLITFIRE_MODEL", "onde-prism"), + ("SPLITFIRE_BASE_URL", "http://x/v1/"), + ]); + assert_eq!( + (c.base_url.as_str(), c.model.as_str()), + ("http://x/v1", "onde-prism") + ); + } + + #[test] + fn onde_base_url_points_onde_at_another_deployment() { + let c = config(&[ + ("ONDE_API_KEY", "app:secret"), + ("ONDE_BASE_URL", "http://localhost:8090/v1/"), + ]); + assert_eq!(c.base_url, "http://localhost:8090/v1"); + let both = config(&[ + ("ONDE_BASE_URL", "http://a/v1"), + ("SPLITFIRE_BASE_URL", "http://b/v1"), + ]); + assert_eq!(both.base_url, "http://b/v1"); + } + + #[test] + fn openai_key_selects_a_generic_endpoint() { + let c = config(&[ + ("OPENAI_API_KEY", "sk"), + ("OPENAI_MODEL", "my-model"), + ("OPENAI_BASE_URL", "http://x/v1/"), + ("ONDE_BASE_URL", "http://onde/v1"), + ]); + assert_eq!( + ( + c.provider, + c.base_url.as_str(), + c.api_key.as_deref(), + c.model.as_str() + ), + (Provider::OpenAi, "http://x/v1", Some("sk"), "my-model") + ); + let plain = config(&[("OPENAI_API_KEY", "sk")]); + assert_eq!( + (plain.base_url.as_str(), plain.model.as_str()), + ("https://api.openai.com/v1", "gpt-4o-mini") + ); + assert_eq!(config(&[]).provider, Provider::Onde); + } + + #[test] + fn onde_wins_over_openai_variables() { + let c = config(&[ + ("ONDE_API_KEY", "app:secret"), + ("OPENAI_API_KEY", "sk-other"), + ("OPENAI_BASE_URL", "http://elsewhere/v1"), + ("OPENAI_MODEL", "gpt-x"), + ]); + assert_eq!(c.provider, Provider::Onde); + assert_eq!(c.api_key.as_deref(), Some("app:secret")); + assert_eq!(c.base_url, "https://cloud.ondeinference.com/v1"); + assert_eq!(c.model, "onde-kkk"); + } + + #[test] + fn explicit_provider_wins_over_detected_keys() { + let c = config(&[ + ("ONDE_API_KEY", "app:secret"), + ("OPENAI_API_KEY", "sk"), + ("SPLITFIRE_PROVIDER", "openai"), + ("SPLITFIRE_MODEL", "m"), + ]); + assert_eq!( + (c.provider, c.api_key.as_deref(), c.model.as_str()), + (Provider::OpenAi, Some("sk"), "m") + ); + let onde = config(&[("SPLITFIRE_PROVIDER", "onde"), ("OPENAI_API_KEY", "sk")]); + assert_eq!((onde.provider, onde.api_key), (Provider::Onde, None)); + } + + /// One test touches the process environment, so it can't race another. + #[test] + fn key_from_env_then_file_ignoring_empty_env() { + let dir = std::env::temp_dir().join(format!("sf-llm-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("env"), + "# comment\nexport ONDE_API_KEY=\"file:key\"\nSPLITFIRE_MODEL=m1\n", + ) + .unwrap(); + // SAFETY: only this test mutates these variables. + unsafe { + std::env::set_var("SPLITFIRE_DATA_DIR", &dir); + std::env::set_var("ONDE_API_KEY", ""); + } + assert_eq!(ENV.config_file_candidates(), vec![dir.join("env")]); + let c = LlmConfig::from_env(&ENV); + assert_eq!(c.api_key.as_deref(), Some("file:key")); + assert_eq!(c.model, "m1"); + unsafe { std::env::set_var("ONDE_API_KEY", "env:key") }; + assert_eq!( + LlmConfig::from_env(&ENV).api_key.as_deref(), + Some("env:key") + ); + unsafe { + std::env::remove_var("ONDE_API_KEY"); + std::env::remove_var("SPLITFIRE_DATA_DIR"); + } + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn usage_tolerates_missing_total() { + let chunk: Chunk = serde_json::from_str( + r#"{"choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3}}"#, + ) + .unwrap(); + assert_eq!(chunk.usage.unwrap().used(), 10); + let full = Usage { + prompt_tokens: 1, + completion_tokens: 1, + total_tokens: 5, + }; + assert_eq!(full.used(), 5); + } + + #[test] + fn parses_models_response() { + let body = r#"{ + "object": "list", + "data": [ + {"id": "onde-balanced", "object": "model", "created": 1700000000, "owned_by": "onde"}, + {"id": "custom-model"} + ] + }"#; + let parsed: ModelsResponse = serde_json::from_str(body).unwrap(); + assert_eq!(parsed.data.len(), 2); + assert_eq!(parsed.data[0].owned_by.as_deref(), Some("onde")); + assert_eq!(parsed.data[1].owned_by, None); + } +} + +#[derive(Debug, Clone, Default)] +pub struct ToolCallRequest { + pub id: String, + pub name: String, + pub arguments: String, + /// Provider extras that must be echoed back (e.g. Gemini's `thought_signature`). + pub extra_content: Option, +} + +#[derive(Debug, Default)] +pub struct Completion { + pub content: String, + pub tool_calls: Vec, + pub finish_reason: Option, + /// Token usage, when the endpoint reports it (`stream_options.include_usage`). + pub usage: Option, +} + +#[derive(Debug, Clone, Copy, Default, Deserialize)] +pub struct Usage { + #[serde(default)] + pub prompt_tokens: u64, + #[serde(default)] + pub completion_tokens: u64, + #[serde(default)] + pub total_tokens: u64, +} + +impl Usage { + /// Tokens in the context after this completion. Some servers leave `total_tokens` at 0. + pub fn used(&self) -> u64 { + self.total_tokens + .max(self.prompt_tokens + self.completion_tokens) + } +} + +impl Completion { + /// The assistant message to append to the conversation history. + pub fn to_message(&self) -> Value { + let mut msg = json!({ "role": "assistant", "content": self.content }); + if !self.tool_calls.is_empty() { + msg["tool_calls"] = self + .tool_calls + .iter() + .map(|tc| { + let mut call = json!({ + "id": tc.id, + "type": "function", + "function": { "name": tc.name, "arguments": tc.arguments }, + }); + if let Some(extra) = &tc.extra_content { + call["extra_content"] = extra.clone(); + } + call + }) + .collect(); + } + msg + } +} + +/// Streamed pieces surfaced to the caller as they arrive. +pub enum Delta<'a> { + Text(&'a str), + Reasoning(&'a str), +} + +#[derive(Deserialize)] +struct Chunk { + #[serde(default)] + choices: Vec, + usage: Option, +} + +#[derive(Deserialize)] +struct Choice { + #[serde(default)] + delta: ChunkDelta, + finish_reason: Option, +} + +#[derive(Deserialize, Default)] +struct ChunkDelta { + content: Option, + // Non-standard but common (DeepSeek, vLLM, OpenRouter, llama.cpp). + reasoning_content: Option, + reasoning: Option, + #[serde(default)] + tool_calls: Vec, +} + +#[derive(Deserialize)] +struct ToolCallDelta { + #[serde(default)] + index: usize, + id: Option, + function: Option, + extra_content: Option, +} + +#[derive(Deserialize)] +struct FunctionDelta { + name: Option, + arguments: Option, +} + +/// A model from `GET /v1/models`. Extra fields in the response are ignored. +#[derive(Debug, Clone, Deserialize)] +pub struct ModelInfo { + pub id: String, + #[serde(default)] + pub owned_by: Option, +} + +#[derive(Deserialize)] +struct ModelsResponse { + data: Vec, +} + +#[derive(Debug, Clone)] +pub struct LlmClient { + http: reqwest::Client, + config: LlmConfig, +} + +impl LlmClient { + pub fn new(config: LlmConfig) -> Self { + Self { + http: reqwest::Client::new(), + config, + } + } + + pub fn model(&self) -> &str { + &self.config.model + } + + pub fn config(&self) -> &LlmConfig { + &self.config + } + + /// Whether the provider can be used: an API key is configured, or the provider needs none + /// ([`Provider::Local`]). The agent answers `AUTH_REQUIRED` when this is false. + pub fn has_api_key(&self) -> bool { + self.config.provider == Provider::Local || self.config.api_key.is_some() + } + + /// Verify the configured key with a minimal chat completion. + /// Returns `Err` when no key is set, the key is rejected, or the endpoint is unreachable. + pub async fn check_auth(&self) -> Result<()> { + let provider = self.config.provider; + if provider == Provider::Local { + return Ok(()); + } + let Some(key) = &self.config.api_key else { + bail!("no {} configured", provider.key_var()); + }; + let body = json!({ + "model": self.config.model, + "messages": [{ "role": "user", "content": "ping" }], + "max_tokens": 1, + "stream": false, + }); + let resp = self + .http + .post(format!("{}/chat/completions", self.config.base_url)) + .json(&body) + .bearer_auth(key) + .send() + .await + .with_context(|| format!("reaching {}", provider.display_name()))?; + let status = resp.status(); + if matches!(status.as_u16(), 401 | 403) { + bail!( + "{} rejected the API key ({status})", + provider.display_name() + ); + } + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + bail!("{} returned {status}: {text}", provider.display_name()); + } + Ok(()) + } + + /// List the models the configured endpoint serves (`GET {base_url}/models`). + pub async fn models(&self) -> Result> { + if self.config.provider == Provider::Local { + return Ok(crate::local::catalog()); + } + let url = format!("{}/models", self.config.base_url); + let mut req = self.http.get(&url); + if let Some(key) = &self.config.api_key { + req = req.bearer_auth(key); + } + let mut resp = req.send().await.context("sending models request")?; + // Some endpoints (Onde Cloud) list models publicly but reject an invalid key, so a + // bad key shouldn't hide the catalog: retry without auth. + if self.config.api_key.is_some() && matches!(resp.status().as_u16(), 401 | 403) { + resp = self + .http + .get(&url) + .send() + .await + .context("sending models request")?; + } + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + bail!("models endpoint returned {status}: {text}"); + } + let mut models = resp + .json::() + .await + .context("parsing models response")? + .data; + models.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(models) + } + + /// Run one streaming chat completion, invoking `on_delta` for every text fragment. + pub async fn complete( + &self, + model: &str, + messages: &[Value], + tools: &Value, + mut on_delta: impl FnMut(Delta<'_>), + ) -> Result { + if self.config.provider == Provider::Local { + return crate::local::complete(model, messages, tools, on_delta).await; + } + if self.config.api_key.is_none() { + bail!( + "No API key configured. Set {} in the environment or run the agent's `--setup`", + self.config.provider.key_var() + ); + } + let mut body = json!({ + "model": model, + "messages": messages, + "tools": tools, + "stream": true, + "stream_options": { "include_usage": true }, + }); + let send = |body: &Value| { + let mut req = self + .http + .post(format!("{}/chat/completions", self.config.base_url)) + .json(body); + if let Some(key) = &self.config.api_key { + req = req.bearer_auth(key); + } + req.send() + }; + let mut resp = send(&body) + .await + .context("sending chat completion request")?; + // Not every OpenAI-compatible server knows `stream_options`; retry without it. + if matches!(resp.status().as_u16(), 400 | 422) { + body.as_object_mut().unwrap().remove("stream_options"); + resp = send(&body) + .await + .context("sending chat completion request")?; + } + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + bail!("LLM endpoint returned {status}: {text}"); + } + + let mut out = Completion::default(); + let mut stream = resp.bytes_stream(); + let mut buf: Vec = Vec::new(); + while let Some(bytes) = stream.next().await { + buf.extend_from_slice(&bytes.context("reading response stream")?); + while let Some(pos) = buf.iter().position(|&b| b == b'\n') { + let line: Vec = buf.drain(..=pos).collect(); + let line = String::from_utf8_lossy(&line); + let Some(data) = line.trim().strip_prefix("data:") else { + continue; + }; + let data = data.trim(); + if data == "[DONE]" { + return Ok(out); + } + let chunk: Chunk = match serde_json::from_str(data) { + Ok(c) => c, + Err(e) => { + tracing::warn!("skipping unparseable chunk ({e}): {data}"); + continue; + } + }; + if chunk.usage.is_some() { + out.usage = chunk.usage; + } + for choice in chunk.choices { + let d = choice.delta; + if let Some(r) = d.reasoning_content.as_deref().or(d.reasoning.as_deref()) + && !r.is_empty() + { + on_delta(Delta::Reasoning(r)); + } + if let Some(t) = d.content.as_deref() + && !t.is_empty() + { + out.content.push_str(t); + on_delta(Delta::Text(t)); + } + for tc in d.tool_calls { + if out.tool_calls.len() <= tc.index { + out.tool_calls + .resize(tc.index + 1, ToolCallRequest::default()); + } + let slot = &mut out.tool_calls[tc.index]; + if let Some(id) = tc.id { + slot.id = id; + } + if tc.extra_content.is_some() { + slot.extra_content = tc.extra_content; + } + if let Some(f) = tc.function { + if let Some(n) = f.name { + slot.name.push_str(&n); + } + if let Some(a) = f.arguments { + slot.arguments.push_str(&a); + } + } + } + if choice.finish_reason.is_some() { + out.finish_reason = choice.finish_reason; + } + } + } + } + Ok(out) + } +} diff --git a/crates/ed-acp/src/local.rs b/crates/ed-acp/src/local.rs new file mode 100644 index 0000000..337fcfb --- /dev/null +++ b/crates/ed-acp/src/local.rs @@ -0,0 +1,333 @@ +//! [`Provider::Local`](crate::llm::Provider::Local): the turn loop on an on-device model, with +//! onde. +//! +//! The rest of `ed-acp` keeps the conversation as OpenAI chat-completions JSON and calls +//! [`LlmClient::complete`](crate::llm::LlmClient::complete) once per model turn with all of it. +//! This module converts that JSON to onde's [`ChatTurn`]s and runs +//! [`ChatEngine::complete`], which takes the whole conversation each call and leaves the +//! engine's own history alone. One engine serves the process; it loads the requested model on +//! first use (downloading it the first time) and swaps it when the session picks another. +//! +//! onde doesn't stream tool-aware turns yet, so a turn's reasoning and text are reported +//! through `on_delta` once the turn ends. +//! +//! Without the `local` feature the provider is still selectable but every turn fails with an +//! error that says how to enable it. + +#[cfg(feature = "local")] +pub use enabled::{catalog, complete}; + +#[cfg(not(feature = "local"))] +pub use disabled::{catalog, complete}; + +#[cfg(not(feature = "local"))] +mod disabled { + use serde_json::Value; + + use crate::llm::{Completion, Delta, ModelInfo}; + + pub fn catalog() -> Vec { + Vec::new() + } + + pub async fn complete( + _model: &str, + _messages: &[Value], + _tools: &Value, + _on_delta: impl FnMut(Delta<'_>), + ) -> anyhow::Result { + anyhow::bail!( + "this agent was built without on-device inference; rebuild it with ed-acp's `local` \ + feature, or choose another provider" + ) + } +} + +#[cfg(feature = "local")] +mod enabled { + use std::sync::OnceLock; + + use anyhow::{Context, Result, anyhow, bail}; + use onde::inference::models::{SUPPORTED_MODEL_INFO, TOOL_CALLING_MODELS}; + use onde::inference::{ + ChatEngine, ChatTurn, GgufModelConfig, ToolCallRequest as OndeCall, ToolCallingSupport, + ToolDefinition, + }; + use serde_json::Value; + use tokio::sync::Mutex; + + use crate::llm::{Completion, Delta, ModelInfo, ToolCallRequest, Usage}; + + /// The process's engine and the model loaded in it. The mutex is held across a turn, so + /// sessions take turns on the one model instead of swapping it under each other. + struct Local { + engine: ChatEngine, + loaded: Mutex>, + } + + fn local() -> &'static Local { + static LOCAL: OnceLock = OnceLock::new(); + LOCAL.get_or_init(|| Local { + engine: ChatEngine::new(), + loaded: Mutex::new(None), + }) + } + + /// onde's tool-calling models, the ones an agent can drive. + pub fn catalog() -> Vec { + TOOL_CALLING_MODELS + .iter() + .map(|id| ModelInfo { + id: (*id).to_string(), + owned_by: SUPPORTED_MODEL_INFO + .iter() + .find(|info| info.id == *id) + .map(|info| format!("{} (on device)", info.name)), + }) + .collect() + } + + pub async fn complete( + model: &str, + messages: &[Value], + tools: &Value, + mut on_delta: impl FnMut(Delta<'_>), + ) -> Result { + let turns = to_turns(messages)?; + let tools = to_tools(tools); + let local = local(); + let mut loaded = local.loaded.lock().await; + if loaded.as_deref() != Some(model) { + let config = GgufModelConfig::from_supported_model_id(model) + .ok_or_else(|| anyhow!("{model} is not an on-device model onde supports"))?; + if onde::inference::models::tool_calling_support(model) != ToolCallingSupport::Supported + { + bail!("{model} can't call tools reliably, so it can't run an agent"); + } + on_delta(Delta::Reasoning(&format!( + "Loading {} on this device. The first use downloads it.\n", + config.display_name + ))); + tracing::info!("loading on-device model {model}"); + *loaded = None; + local + .engine + .load_gguf_model(config, None, None) + .await + .with_context(|| format!("loading {model}"))?; + *loaded = Some(model.to_string()); + } + let result = local + .engine + .complete(turns, &tools, None) + .await + .context("on-device inference")?; + drop(loaded); + + if let Some(reasoning) = &result.reasoning { + on_delta(Delta::Reasoning(reasoning)); + } + if !result.text.is_empty() { + on_delta(Delta::Text(&result.text)); + } + Ok(Completion { + content: result.text, + tool_calls: result + .tool_calls + .into_iter() + .map(|call| ToolCallRequest { + id: call.id, + name: call.function_name, + arguments: call.arguments, + extra_content: None, + }) + .collect(), + finish_reason: Some(result.finish_reason), + usage: Some(Usage { + prompt_tokens: result.prompt_tokens as u64, + completion_tokens: result.completion_tokens as u64, + total_tokens: (result.prompt_tokens + result.completion_tokens) as u64, + }), + }) + } + + /// OpenAI chat messages, as the turn loop keeps them, to onde turns. + pub(super) fn to_turns(messages: &[Value]) -> Result> { + messages + .iter() + .map(|message| { + let role = message.get("role").and_then(Value::as_str).unwrap_or(""); + let content = text_of(message.get("content")); + Ok(match role { + "system" | "developer" => ChatTurn::System(content), + "user" => ChatTurn::User(content), + "assistant" => ChatTurn::Assistant { + content, + tool_calls: message + .get("tool_calls") + .and_then(Value::as_array) + .map(|calls| calls.iter().map(to_call).collect()) + .unwrap_or_default(), + }, + "tool" => ChatTurn::Tool { + tool_call_id: message + .get("tool_call_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + content, + }, + other => bail!("unexpected message role {other:?}"), + }) + }) + .collect() + } + + fn to_call(call: &Value) -> OndeCall { + let field = |pointer: &str| { + call.pointer(pointer) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }; + OndeCall { + id: field("/id"), + function_name: field("/function/name"), + arguments: field("/function/arguments"), + } + } + + /// A message's text: a string, or the text parts of a content array. On-device models here + /// are text-only, so other parts become a short placeholder. + fn text_of(content: Option<&Value>) -> String { + match content { + Some(Value::String(text)) => text.clone(), + Some(Value::Array(parts)) => parts + .iter() + .map(|part| match part.get("type").and_then(Value::as_str) { + Some("text") => part + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + Some(kind) => format!("[{kind} omitted: the on-device model reads text only]"), + None => String::new(), + }) + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n"), + _ => String::new(), + } + } + + /// OpenAI `tools` entries to onde tool definitions. + pub(super) fn to_tools(tools: &Value) -> Vec { + tools + .as_array() + .map(|tools| { + tools + .iter() + .filter_map(|tool| { + let function = tool.get("function")?; + Some(ToolDefinition { + name: function.get("name")?.as_str()?.to_string(), + description: function + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + parameters_schema: function + .get("parameters") + .cloned() + .unwrap_or_else(|| serde_json::json!({"type": "object"})) + .to_string(), + }) + }) + .collect() + }) + .unwrap_or_default() + } +} + +#[cfg(all(test, feature = "local"))] +mod tests { + use onde::inference::{ChatTurn, ToolCallRequest}; + use serde_json::json; + + use super::enabled::{catalog, to_tools, to_turns}; + use crate::llm::LOCAL_DEFAULT_MODEL; + + #[test] + fn the_default_model_is_in_the_catalog() { + assert!(catalog().iter().any(|m| m.id == LOCAL_DEFAULT_MODEL)); + assert!(catalog().iter().all(|m| { + m.owned_by + .as_deref() + .is_some_and(|o| o.ends_with("(on device)")) + })); + } + + #[test] + fn messages_become_turns() { + let messages = vec![ + json!({"role": "system", "content": "You are SplitFire."}), + json!({"role": "user", "content": [ + {"type": "text", "text": "What key is this?"}, + {"type": "image_url", "image_url": {"url": "data:..."}} + ]}), + json!({"role": "assistant", "content": "", "tool_calls": [ + {"id": "call_1", "type": "function", + "function": {"name": "analyze_audio", "arguments": "{\"path\":\"a.wav\"}"}} + ]}), + json!({"role": "tool", "tool_call_id": "call_1", "content": "Key: A minor"}), + json!({"role": "assistant", "content": "A minor."}), + ]; + let turns = to_turns(&messages).unwrap(); + assert_eq!( + turns, + vec![ + ChatTurn::System("You are SplitFire.".into()), + ChatTurn::User( + "What key is this?\n[image_url omitted: the on-device model reads text only]" + .into() + ), + ChatTurn::Assistant { + content: String::new(), + tool_calls: vec![ToolCallRequest { + id: "call_1".into(), + function_name: "analyze_audio".into(), + arguments: "{\"path\":\"a.wav\"}".into(), + }], + }, + ChatTurn::Tool { + tool_call_id: "call_1".into(), + content: "Key: A minor".into(), + }, + ChatTurn::Assistant { + content: "A minor.".into(), + tool_calls: vec![], + }, + ] + ); + assert!(to_turns(&[json!({"role": "narrator", "content": "x"})]).is_err()); + } + + #[test] + fn tools_become_definitions() { + let tools = json!([ + {"type": "function", "function": { + "name": "theory_chord", "description": "Spell a chord.", + "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}} + }}, + {"type": "function", "function": {"name": "no_params"}}, + {"type": "not-a-function"} + ]); + let defs = to_tools(&tools); + assert_eq!(defs.len(), 2); + assert_eq!(defs[0].name, "theory_chord"); + assert_eq!(defs[0].description, "Spell a chord."); + let schema: serde_json::Value = serde_json::from_str(&defs[0].parameters_schema).unwrap(); + assert_eq!(schema["properties"]["symbol"]["type"], "string"); + assert_eq!(defs[1].parameters_schema, r#"{"type":"object"}"#); + } +} diff --git a/crates/ed-acp/src/server.rs b/crates/ed-acp/src/server.rs new file mode 100644 index 0000000..f558f84 --- /dev/null +++ b/crates/ed-acp/src/server.rs @@ -0,0 +1,302 @@ +//! The stdio wiring: one handler per ACP method, each spawned off the dispatch loop when it +//! awaits anything (a model list, MCP servers, disk, or a turn that makes client requests). + +use std::sync::Arc; + +use agent_client_protocol::schema::v1::{ + AgentAuthCapabilities, AgentCapabilities, AuthenticateRequest, CancelNotification, + CloseSessionRequest, DeleteSessionRequest, Implementation, InitializeRequest, + InitializeResponse, ListSessionsRequest, LoadSessionRequest, LogoutCapabilities, LogoutRequest, + McpCapabilities, NewSessionRequest, PromptRequest, ResumeSessionRequest, + SessionAdditionalDirectoriesCapabilities, SessionCapabilities, SessionCloseCapabilities, + SessionDeleteCapabilities, SessionListCapabilities, SessionResumeCapabilities, + SetSessionConfigOptionRequest, +}; +use agent_client_protocol::{Agent, ConnectTo, Stdio}; + +use crate::agent::{self, AcpServer}; +use crate::{AgentInfo, Profile, cli}; + +/// How the server was started. +#[derive(Debug, Clone, Copy)] +pub struct ServeOptions { + /// Default for each session's auto-approve: `--yolo` or `_YOLO=1`. + pub yolo: bool, + /// `tui` when launched by the terminal UI (it sets `_SURFACE=tui`), else `acp`. + pub surface: &'static str, +} + +impl ServeOptions { + /// `_YOLO` and `_SURFACE` from the environment. + pub fn from_env(info: &AgentInfo) -> Self { + Self { + yolo: cli::env_flag(&info.env.var("YOLO")), + surface: cli::surface(&info.env), + } + } +} + +/// Serve ACP over stdin/stdout until the client disconnects, then stop every MCP server. +/// Logs must go to stderr: stdout carries JSON-RPC only. +pub async fn serve_stdio( + profile: Arc, + opts: ServeOptions, +) -> agent_client_protocol::Result<()> { + serve(profile, opts, Stdio::new()).await +} + +/// Serve ACP over any transport until the client disconnects, then stop every MCP server. +/// +/// This is how a host embeds the agent in its own process instead of launching it as a +/// subprocess, for example an app in the macOS App Store sandbox: connect it to +/// [`Channel::duplex`](agent_client_protocol::Channel::duplex) and run an ACP client on the +/// other end. +/// +/// One call serves one connection: the transport is consumed, and the future resolves when +/// that client hangs up. To serve another client, call `serve` again with a new transport. +pub async fn serve( + profile: Arc, + opts: ServeOptions, + transport: impl ConnectTo + 'static, +) -> agent_client_protocol::Result<()> { + let info = profile.info(); + let prompt_caps = profile.prompt_capabilities(); + let agent = AcpServer::new(profile, opts.yolo, opts.surface); + tracing::info!("{} starting with model {}", info.name, agent.model()); + + let result = Agent + .builder() + .name(info.name) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: InitializeRequest, responder, _cx| { + agent.set_client_caps(req.client_capabilities.clone()); + // Terminal auth is only usable by clients that can run it. + let methods = if agent::supports_terminal_auth(&req.client_capabilities) { + agent::auth_methods() + } else { + Vec::new() + }; + responder.respond( + InitializeResponse::new(agent::negotiate_version(req.protocol_version)) + .auth_methods(methods) + .agent_capabilities( + AgentCapabilities::new() + .prompt_capabilities(prompt_caps.clone()) + .session_capabilities( + SessionCapabilities::new() + .list(SessionListCapabilities::new()) + .additional_directories( + SessionAdditionalDirectoriesCapabilities::new(), + ) + .resume(SessionResumeCapabilities::new()) + .close(SessionCloseCapabilities::new()) + .delete(SessionDeleteCapabilities::new()), + ) + .auth( + AgentAuthCapabilities::new() + .logout(LogoutCapabilities::new()), + ) + .mcp_capabilities(McpCapabilities::new().http(true)) + .load_session(true), + ) + .agent_info(Implementation::new(info.name, info.version)), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: AuthenticateRequest, responder, cx| { + let agent = agent.clone(); + cx.spawn(async move { + match agent.authenticate(req).await { + Ok(resp) => responder.respond(resp), + Err(e) => responder.respond_with_error(e), + } + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |_req: LogoutRequest, responder, cx| { + let agent = agent.clone(); + cx.spawn(async move { + match agent.logout().await { + Ok(resp) => responder.respond(resp), + Err(e) => responder.respond_with_error(e), + } + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: NewSessionRequest, responder, cx| { + // Listing models and starting MCP servers are slow; keep them off the + // dispatch loop. + let agent = agent.clone(); + let connection = cx.clone(); + cx.spawn(async move { + // Picks up a key stored by `--setup` since this process started. + if !agent.refresh_credentials().await { + return responder.respond_with_error(agent.auth_required()); + } + match agent.new_session(req).await { + Ok(resp) => { + let id = resp.session_id.clone(); + responder.respond(resp)?; + agent.send_commands(&connection, &id) + } + Err(e) => responder.respond_with_error(e), + } + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: ListSessionsRequest, responder, cx| { + // Reads the session directory from disk. + let agent = agent.clone(); + cx.spawn(async move { + match tokio::task::spawn_blocking(move || agent.list_sessions(req)).await { + Ok(Ok(resp)) => responder.respond(resp), + Ok(Err(e)) => responder.respond_with_error(e), + Err(e) => responder.respond_with_internal_error(e.to_string()), + } + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: LoadSessionRequest, responder, cx| { + let agent = agent.clone(); + let connection = cx.clone(); + cx.spawn(async move { + // Editors reopen a thread with session/load after terminal auth, so + // AUTH_REQUIRED here shows sign-in instead of a dead thread. + if !agent.refresh_credentials().await { + return responder.respond_with_error(agent.auth_required()); + } + let id = req.session_id.clone(); + match agent.load_session(req, &connection).await { + Ok(resp) => { + responder.respond(resp)?; + agent.send_commands(&connection, &id) + } + Err(e) => responder.respond_with_error(e), + } + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: ResumeSessionRequest, responder, cx| { + let agent = agent.clone(); + let connection = cx.clone(); + cx.spawn(async move { + if !agent.refresh_credentials().await { + return responder.respond_with_error(agent.auth_required()); + } + let id = req.session_id.clone(); + match agent.resume_session(req, &connection).await { + Ok(resp) => { + responder.respond(resp)?; + agent.send_commands(&connection, &id) + } + Err(e) => responder.respond_with_error(e), + } + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: CloseSessionRequest, responder, _cx| match agent.close_session(req) + { + Ok(resp) => responder.respond(resp), + Err(e) => responder.respond_with_error(e), + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: DeleteSessionRequest, responder, _cx| match agent + .delete_session(req) + { + Ok(resp) => responder.respond(resp), + Err(e) => responder.respond_with_error(e), + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: SetSessionConfigOptionRequest, responder, cx| { + let agent = agent.clone(); + let connection = cx.clone(); + cx.spawn(async move { + match agent.set_config_option(req, &connection).await { + Ok(resp) => responder.respond(resp), + Err(e) => responder.respond_with_error(e), + } + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: PromptRequest, responder, cx| { + // Run the turn off the dispatch loop so it can make requests to the client. + let agent = agent.clone(); + let connection = cx.clone(); + cx.spawn(async move { + if !agent.has_api_key() && !agent.refresh_credentials().await { + return responder.respond_with_error(agent.auth_required()); + } + agent.prompt(req, responder, connection).await + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_notification( + { + let agent = agent.clone(); + async move |n: CancelNotification, _cx| { + agent.cancel(&n.session_id); + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .connect_to(transport) + .await; + // The editor went away: stop MCP servers instead of leaving them to be orphaned. + agent.shutdown().await; + result +} diff --git a/crates/ed-acp/src/store.rs b/crates/ed-acp/src/store.rs new file mode 100644 index 0000000..a9a4d62 --- /dev/null +++ b/crates/ed-acp/src/store.rs @@ -0,0 +1,174 @@ +//! Durable sessions: one JSON file per session under `/sessions/`. +//! +//! The history stored here is the OpenAI-style message list without the system prompt +//! (that is rebuilt every turn). Tool results are `role: "tool"` messages, which is what +//! lets `session/load` replay tool calls with their output. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StoredSession { + pub cwd: PathBuf, + #[serde(default)] + pub roots: Vec, + pub model: String, + #[serde(default)] + pub title: Option, + pub updated_at: u64, + #[serde(default)] + pub messages: Vec, +} + +#[derive(Debug, Clone)] +pub struct SessionStore { + dir: PathBuf, +} + +/// Session ids end up in file names: allow only characters that cannot escape the directory. +pub fn sanitize_id(id: &str) -> Result<&str> { + let ok = !id.is_empty() + && id.len() <= 128 + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + if !ok { + bail!("invalid session id"); + } + Ok(id) +} + +impl SessionStore { + pub fn new(data_dir: &Path) -> Self { + Self { + dir: data_dir.join("sessions"), + } + } + + fn path(&self, id: &str) -> Result { + Ok(self.dir.join(format!("{}.json", sanitize_id(id)?))) + } + + /// Atomic write: temp file in the same directory, then rename. + pub fn save(&self, id: &str, session: &StoredSession) -> Result<()> { + let path = self.path(id)?; + std::fs::create_dir_all(&self.dir) + .with_context(|| format!("creating {}", self.dir.display()))?; + let tmp = self.dir.join(format!(".{id}.{}.tmp", uuid::Uuid::new_v4())); + std::fs::write(&tmp, serde_json::to_vec(session)?)?; + std::fs::rename(&tmp, &path).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp); + })?; + Ok(()) + } + + /// `Ok(None)` when no such session exists (or the id is not a valid one). + pub fn load(&self, id: &str) -> Result> { + let Ok(path) = self.path(id) else { + return Ok(None); + }; + match std::fs::read(&path) { + Ok(bytes) => Ok(Some( + serde_json::from_slice(&bytes) + .with_context(|| format!("parsing {}", path.display()))?, + )), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } + } + + /// All stored sessions, newest first. Unreadable files are skipped. + pub fn list(&self) -> Vec<(String, StoredSession)> { + let Ok(rd) = std::fs::read_dir(&self.dir) else { + return Vec::new(); + }; + let mut out: Vec<(String, StoredSession)> = rd + .filter_map(|e| { + let path = e.ok()?.path(); + if path.extension()? != "json" { + return None; + } + let id = path.file_stem()?.to_str()?.to_string(); + let session = serde_json::from_slice(&std::fs::read(&path).ok()?).ok()?; + Some((id, session)) + }) + .collect(); + out.sort_by(|a, b| b.1.updated_at.cmp(&a.1.updated_at).then(a.0.cmp(&b.0))); + out + } + + /// Returns whether a file was removed. + pub fn delete(&self, id: &str) -> Result { + let Ok(path) = self.path(id) else { + return Ok(false); + }; + match std::fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e).with_context(|| format!("deleting {}", path.display())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn tmp() -> PathBuf { + let d = std::env::temp_dir().join(format!("sf-store-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&d).unwrap(); + d + } + + fn sample(updated_at: u64) -> StoredSession { + StoredSession { + cwd: "/work".into(), + roots: vec!["/other".into()], + model: "m".into(), + title: Some("hello".into()), + updated_at, + messages: vec![ + json!({"role": "user", "content": "hi"}), + json!({"role": "tool", "tool_call_id": "c1", "content": "out"}), + ], + } + } + + #[test] + fn round_trip_list_delete() { + let dir = tmp(); + let store = SessionStore::new(&dir); + assert!(store.load("a").unwrap().is_none()); + store.save("a", &sample(10)).unwrap(); + store.save("b", &sample(20)).unwrap(); + assert_eq!(store.load("a").unwrap().unwrap(), sample(10)); + let ids: Vec<_> = store.list().into_iter().map(|(id, _)| id).collect(); + assert_eq!(ids, ["b", "a"]); + assert!(store.delete("a").unwrap()); + assert!(!store.delete("a").unwrap()); + assert!(store.load("a").unwrap().is_none()); + // No temp files left behind. + let leftovers = std::fs::read_dir(dir.join("sessions")) + .unwrap() + .filter(|e| e.as_ref().unwrap().path().extension().unwrap() != "json") + .count(); + assert_eq!(leftovers, 0); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn rejects_path_traversal_ids() { + let dir = tmp(); + let store = SessionStore::new(&dir); + for bad in ["../x", "a/b", "", "a.b", "..", "a\\b"] { + assert!(store.save(bad, &sample(1)).is_err(), "{bad}"); + assert!(store.load(bad).unwrap().is_none()); + assert!(!store.delete(bad).unwrap()); + } + std::fs::remove_dir_all(dir).ok(); + } +} diff --git a/crates/ed-acp/src/tools.rs b/crates/ed-acp/src/tools.rs new file mode 100644 index 0000000..2374d66 --- /dev/null +++ b/crates/ed-acp/src/tools.rs @@ -0,0 +1,462 @@ +//! The tool layer: what a tool gets ([`ToolCtx`]), what it returns ([`ToolOutcome`]), the +//! approval flow, ACP path rules, and dispatch to product [`Toolset`]s and MCP servers. + +use std::collections::HashSet; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use agent_client_protocol::schema::v1::{ + ClientCapabilities, PermissionOption, PermissionOptionKind, RequestPermissionOutcome, + RequestPermissionRequest, SessionId, SessionNotification, SessionUpdate, ToolCallContent, + ToolCallLocation, ToolCallUpdate, ToolCallUpdateFields, ToolKind, +}; +use agent_client_protocol::{Client, ConnectionTo}; +use anyhow::{Result, anyhow, bail}; +use async_trait::async_trait; +use ed_mcp::McpToolset; +use serde_json::{Value, json}; +use tokio_util::sync::CancellationToken; + +use crate::{McpCall, Profile}; + +/// Cap on tool output sent to the model and on client terminal output. +pub const MAX_OUTPUT_BYTES: usize = 32 * 1024; + +/// Everything a tool needs to act on behalf of one session. +pub struct ToolCtx { + pub connection: ConnectionTo, + pub session_id: SessionId, + /// Primary working directory: the base for relative paths and `run_command`. + pub cwd: PathBuf, + /// Additional workspace roots the model may address with the `root` parameter. + pub roots: Vec, + pub caps: ClientCapabilities, + pub cancel: CancellationToken, + /// Approve tool calls without asking: `--yolo`, or the session's `auto_approve` option. + pub auto_approve: Arc, + /// Tool names the user chose "always allow" for in this session. + pub always_allowed: Arc>>, + /// Tool names the user chose "always reject" for in this session. + pub always_rejected: Arc>>, + /// Tools from MCP servers available to this session. + pub mcp: Arc, + /// How long `run_command` may run: `_COMMAND_TIMEOUT_SECS`, default 120 seconds. + pub command_timeout: std::time::Duration, +} + +/// What a tool call produced. +pub struct ToolOutcome { + /// Text returned to the model. + pub text: String, + /// Rich content shown to the user in the client. + pub content: Vec, + pub failed: bool, + /// Files the tool produced or touched, surfaced as tool-call locations. + pub locations: Vec, +} + +impl ToolOutcome { + pub fn ok(text: impl Into) -> Self { + let text = text.into(); + Self { + content: vec![ToolCallContent::from(text.clone())], + text, + failed: false, + locations: Vec::new(), + } + } + pub fn err(text: impl Into) -> Self { + let text = text.into(); + Self { + content: vec![ToolCallContent::from(text.clone())], + text, + failed: true, + locations: Vec::new(), + } + } +} + +/// An OpenAI `tools` entry. +pub fn function_def(name: &str, desc: &str, params: Value) -> Value { + json!({ "type": "function", "function": { "name": name, "description": desc, "parameters": params } }) +} + +/// The session's directories, for describing a tool call without running it (also used when +/// `session/load` replays history). +pub struct DescribeCtx<'a> { + pub cwd: &'a Path, + pub roots: &'a [PathBuf], +} + +/// A group of tools a product offers to the model. +#[async_trait] +pub trait Toolset: Send + Sync { + /// OpenAI `tools` entries, built with [`function_def`]. + fn definitions(&self) -> Vec; + /// Whether `name` is one of this toolset's tools. + fn handles(&self, name: &str) -> bool; + /// Title, kind and affected locations shown in the client when the call starts. Locations + /// must be absolute ([`absolutize`]). + fn describe( + &self, + name: &str, + args: &Value, + ctx: &DescribeCtx<'_>, + ) -> (String, ToolKind, Vec); + /// Run the tool. A mutating tool asks first with [`ToolCtx::permit`]. An `Err` is shown to + /// the model as `Error: …` and the call is marked failed. + async fn execute( + &self, + ctx: &ToolCtx, + tool_call_id: &str, + name: &str, + args: Value, + ) -> Result; +} + +/// All tool definitions for one session, in OpenAI `tools` format: product toolsets first, +/// then MCP tools sorted by name. +pub fn definitions(toolsets: &[Arc], mcp: &McpToolset) -> Value { + let mut defs: Vec = toolsets.iter().flat_map(|t| t.definitions()).collect(); + defs.extend( + mcp.tools() + .into_iter() + .map(|t| function_def(&t.name, &t.description, t.parameters.clone())), + ); + Value::Array(defs) +} + +/// Title, kind and affected locations shown in the client when the call starts. +pub fn describe( + toolsets: &[Arc], + cwd: &Path, + roots: &[PathBuf], + name: &str, + args: &Value, +) -> (String, ToolKind, Vec) { + if let Some((server, tool)) = ed_mcp::split_qualified(name) { + let loc = ["input", "path", "file"] + .iter() + .filter_map(|k| args.get(*k).and_then(Value::as_str)) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .map(|p| ToolCallLocation::new(absolutize(&p))) + .collect(); + return (format!("{server}: {tool}"), ToolKind::Other, loc); + } + let ctx = DescribeCtx { cwd, roots }; + match toolsets.iter().find(|t| t.handles(name)) { + Some(t) => t.describe(name, args, &ctx), + None => (name.to_string(), ToolKind::Other, vec![]), + } +} + +/// Run one tool call: a product toolset's, or an MCP server's. Never fails; errors become a +/// failed outcome the model sees. +pub async fn execute( + ctx: &ToolCtx, + toolsets: &[Arc], + profile: &dyn Profile, + tool_call_id: &str, + name: &str, + args: Value, +) -> ToolOutcome { + let result = if ed_mcp::split_qualified(name).is_some() { + execute_mcp(ctx, profile, tool_call_id, name, args).await + } else if let Some(t) = toolsets.iter().find(|t| t.handles(name)) { + t.execute(ctx, tool_call_id, name, args).await + } else { + Err(anyhow!("unknown tool `{name}`")) + }; + result.unwrap_or_else(|e| ToolOutcome::err(format!("Error: {e:#}"))) +} + +async fn execute_mcp( + ctx: &ToolCtx, + profile: &dyn Profile, + tool_call_id: &str, + name: &str, + args: Value, +) -> Result { + let Some(tool) = ctx.mcp.tool(name) else { + bail!("unknown tool `{name}`"); + }; + if !tool.read_only { + let call = McpCall { + tool, + args: &args, + connection: ctx.mcp.connection(name), + }; + let preview = match profile.mcp_permission_preview(call).await { + Some(text) => text, + None => default_mcp_preview(tool, &args), + }; + let preview = vec![ToolCallContent::from(preview)]; + if !ctx.permit(tool_call_id, name, preview).await? { + return Ok(ToolOutcome::err("User rejected running this tool.")); + } + } + let on_progress = |line: String| { + let _ = ctx.update( + tool_call_id, + ToolCallUpdateFields::new().content(vec![ToolCallContent::from(line)]), + ); + }; + let out = ctx.mcp.call(name, args, &ctx.cancel, &on_progress).await?; + let mut outcome = if out.failed { + ToolOutcome::err(truncate(out.text)) + } else { + ToolOutcome::ok(truncate(out.text)) + }; + if let Some(structured) = &out.structured { + outcome.locations = profile + .mcp_result_locations(tool, structured) + .into_iter() + .filter(|p| p.is_absolute()) + .map(|p| absolutize(&p)) + .collect(); + } + Ok(outcome) +} + +/// The permission prompt for an MCP tool when the product has nothing better to say. +fn default_mcp_preview(tool: &ed_mcp::McpTool, args: &Value) -> String { + let mut text = format!("Run `{}` on the {} MCP server.", tool.tool, tool.server); + if args.as_object().is_some_and(|o| !o.is_empty()) { + let shown = serde_json::to_string_pretty(args).unwrap_or_default(); + text.push_str(&format!("\nArguments:\n```json\n{}\n```", truncate(shown))); + } + text +} + +/// The base directory for a tool call's `root` parameter: a workspace root or any directory +/// beneath one (so the model can pass e.g. `root/crate/src`). Paths that escape a root via +/// `..` are rejected. +pub fn base_for(cwd: &Path, roots: &[PathBuf], root: Option<&str>) -> Result { + let Some(root) = root else { + return Ok(cwd.to_path_buf()); + }; + // Lexical normalization resolves `..`, so `root/../elsewhere` can't pass the prefix check. + let candidate = normalize(Path::new(root)); + let all_roots = all_roots(cwd, roots); + for ws_root in &all_roots { + if candidate == *ws_root { + return Ok(ws_root.clone()); + } + // Allow any directory below a root (e.g. a crate dir like `root/crate/src`), + // as long as it doesn't escape the root via `..`. + if let Ok(rest) = candidate.strip_prefix(ws_root) + && rest.components().all(|c| matches!(c, Component::Normal(_))) + { + return Ok(candidate); + } + } + let known = all_roots + .iter() + .map(|r| r.display().to_string()) + .collect::>() + .join(", "); + bail!( + "unknown root `{}` (workspace roots: {known})", + candidate.display() + ) +} + +fn all_roots(cwd: &Path, roots: &[PathBuf]) -> Vec { + std::iter::once(cwd.to_path_buf()) + .chain(roots.iter().filter(|r| *r != cwd).cloned()) + .collect() +} + +impl ToolCtx { + pub fn base_for(&self, root: Option<&str>) -> Result { + base_for(&self.cwd, &self.roots, root) + } + + pub fn resolve_with(&self, root: Option<&str>, path: &str) -> Result { + Ok(resolve_in(&self.base_for(root)?, path)) + } + + /// Ask the user for permission. Returns `Ok(false)` if rejected or cancelled. Honours + /// "always allow" and "always reject" choices for `tool` and the session's auto-approve. + pub async fn permit( + &self, + id: &str, + tool: &str, + content: Vec, + ) -> Result { + if self.always_rejected.lock().unwrap().contains(tool) { + return Ok(false); + } + if self.auto_approve.load(Ordering::Relaxed) + || self.always_allowed.lock().unwrap().contains(tool) + { + return Ok(true); + } + let mut fields = ToolCallUpdateFields::new(); + if !content.is_empty() { + fields = fields.content(content); + } + let req = RequestPermissionRequest::new( + self.session_id.clone(), + ToolCallUpdate::new(id.to_string(), fields), + vec![ + PermissionOption::new("allow_once", "Allow", PermissionOptionKind::AllowOnce), + PermissionOption::new( + "allow_always", + "Always allow", + PermissionOptionKind::AllowAlways, + ), + PermissionOption::new("reject_once", "Reject", PermissionOptionKind::RejectOnce), + PermissionOption::new( + "reject_always", + "Always reject", + PermissionOptionKind::RejectAlways, + ), + ], + ); + let resp = tokio::select! { + r = self.connection.send_request(req).block_task() => r?, + () = self.cancel.cancelled() => return Ok(false), + }; + Ok(match resp.outcome { + RequestPermissionOutcome::Selected(sel) => match &*sel.option_id.0 { + "allow_always" => { + self.always_allowed.lock().unwrap().insert(tool.to_string()); + true + } + "allow_once" => true, + "reject_always" => { + self.always_rejected + .lock() + .unwrap() + .insert(tool.to_string()); + false + } + _ => false, + }, + _ => false, + }) + } + + pub fn update(&self, id: &str, fields: ToolCallUpdateFields) -> Result<()> { + let update = SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(id.to_string(), fields)); + self.connection + .send_notification(SessionNotification::new(self.session_id.clone(), update))?; + Ok(()) + } +} + +/// Join `path` onto `base`, keeping absolute paths as-is. +pub fn resolve_in(base: &Path, path: &str) -> PathBuf { + let p = Path::new(path); + if p.is_absolute() { + p.to_path_buf() + } else { + base.join(p) + } +} + +/// Cut `s` to [`MAX_OUTPUT_BYTES`] on a character boundary, marking the cut. +pub fn truncate(mut s: String) -> String { + if s.len() > MAX_OUTPUT_BYTES { + let mut cut = MAX_OUTPUT_BYTES; + while !s.is_char_boundary(cut) { + cut -= 1; + } + s.truncate(cut); + s.push_str("\n[truncated]"); + } + s +} + +/// Make `path` absolute and free of `.`/`..` components, as the ACP v1 spec requires +/// absolute paths everywhere. Existing paths are canonicalized (resolving symlinks +/// and giving the client the true on-disk location); paths that don't exist yet +/// are normalized lexically with the existing parent canonicalized when possible. +pub fn absolutize(path: &Path) -> PathBuf { + if let Ok(c) = std::fs::canonicalize(path) { + return c; + } + // Not on disk (yet): normalize lexically, anchoring at the canonicalized parent. + let parent = path.parent().filter(|p| !p.as_os_str().is_empty()); + let base = parent.and_then(|p| std::fs::canonicalize(p).ok()); + let file_name = path.file_name().map(|f| f.to_os_string()); + match (base, file_name) { + (Some(mut b), Some(f)) => { + b.push(f); + b + } + _ => normalize(path), + } +} + +/// Lexically normalize a path: resolve `.` and `..` without touching the filesystem. +/// Absolute paths stay absolute; relative paths are made absolute against the cwd. +pub fn normalize(path: &Path) -> PathBuf { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + }; + let mut out: Vec = Vec::new(); + for c in absolute.components() { + match c { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str().to_os_string()), + } + } + out.into_iter().collect() +} + +#[cfg(test)] +mod fs_path_tests { + use super::*; + + #[test] + fn normalize_resolves_dot_dot_lexically() { + assert_eq!(normalize(Path::new("/a/b/../c")), PathBuf::from("/a/c")); + assert_eq!(normalize(Path::new("/a/./b")), PathBuf::from("/a/b")); + assert_eq!(normalize(Path::new("/a/b/..")), PathBuf::from("/a")); + } + + #[test] + fn normalize_makes_relative_paths_absolute() { + let n = normalize(Path::new("src/../README.md")); + assert!(n.is_absolute()); + assert!(n.ends_with("README.md")); + assert!(!n.components().any(|c| c == Component::ParentDir)); + } + + #[test] + fn absolutize_canonicalizes_existing_paths() { + let n = absolutize(&std::env::temp_dir()); + assert!(n.is_absolute()); + assert!(!n.components().any(|c| c == Component::ParentDir)); + } + + #[test] + fn absolutize_normalizes_missing_paths() { + let n = absolutize(Path::new("/definitely/does/not/../exist.txt")); + assert_eq!(n, PathBuf::from("/definitely/does/exist.txt")); + } + + #[test] + fn base_for_rejects_escapes_and_accepts_subdirs() { + let cwd = PathBuf::from("/ws/a"); + let roots = vec![PathBuf::from("/ws/b")]; + assert_eq!(base_for(&cwd, &roots, None).unwrap(), cwd); + assert_eq!( + base_for(&cwd, &roots, Some("/ws/b/sub/../x")).unwrap(), + PathBuf::from("/ws/b/x") + ); + assert!(base_for(&cwd, &roots, Some("/ws/b/../c")).is_err()); + assert!(base_for(&cwd, &roots, Some("/etc")).is_err()); + } +} diff --git a/crates/ed-acp/src/workspace.rs b/crates/ed-acp/src/workspace.rs new file mode 100644 index 0000000..70170c9 --- /dev/null +++ b/crates/ed-acp/src/workspace.rs @@ -0,0 +1,456 @@ +//! Workspace tools: `read_file`, `write_file`, `edit_file`, `list_directory` and +//! `run_command`, routed through the ACP client (`fs/*`, `terminal/*`) when it advertises +//! support and falling back to the local filesystem and `sh -c` otherwise. Every tool takes an +//! optional `root` for multi-root workspaces. Writes and commands ask for approval. + +use std::path::Path; + +use agent_client_protocol::ErrorCode; +use agent_client_protocol::schema::v1::{ + CreateTerminalRequest, Diff, KillTerminalRequest, ReadTextFileRequest, ReleaseTerminalRequest, + Terminal, TerminalOutputRequest, ToolCallContent, ToolCallLocation, ToolCallUpdateFields, + ToolKind, WaitForTerminalExitRequest, WriteTextFileRequest, +}; +use anyhow::{Context, Result, anyhow, bail}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::tools::{ + DescribeCtx, MAX_OUTPUT_BYTES, ToolCtx, ToolOutcome, Toolset, absolutize, base_for, + function_def, resolve_in, truncate, +}; + +/// The workspace tools as a [`Toolset`]. +#[derive(Debug, Default, Clone, Copy)] +pub struct WorkspaceTools; + +const NAMES: &[&str] = &[ + "read_file", + "write_file", + "edit_file", + "list_directory", + "run_command", +]; + +fn tool_definitions() -> Vec { + let f = |name: &str, desc: &str, params: Value| function_def(name, desc, params); + let root_prop = || { + json!({ + "type": "string", + "description": "Workspace root to act in: absolute path of a session root or a directory beneath one. Defaults to the primary working directory." + }) + }; + vec![ + f( + "read_file", + "Read a text file. Paths may be relative to the working directory.", + json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "line": { "type": "integer", "description": "1-based line to start from" }, + "limit": { "type": "integer", "description": "Max lines to read" }, + "root": root_prop() + }, + "required": ["path"] + }), + ), + f( + "write_file", + "Create or overwrite a file with the given content.", + json!({ + "type": "object", + "properties": { "path": { "type": "string" }, "content": { "type": "string" }, "root": root_prop() }, + "required": ["path", "content"] + }), + ), + f( + "edit_file", + "Replace an exact, unique occurrence of old_string with new_string in a file.", + json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "old_string": { "type": "string" }, + "new_string": { "type": "string" }, + "root": root_prop() + }, + "required": ["path", "old_string", "new_string"] + }), + ), + f( + "list_directory", + "List entries of a directory (directories end with '/').", + json!({ + "type": "object", + "properties": { "path": { "type": "string", "description": "Defaults to the working directory" }, "root": root_prop() } + }), + ), + f( + "run_command", + "Run a shell command (sh -c) in the working directory and return its output.", + json!({ + "type": "object", + "properties": { "command": { "type": "string" }, "root": root_prop() }, + "required": ["command"] + }), + ), + ] +} + +#[async_trait] +impl Toolset for WorkspaceTools { + fn definitions(&self) -> Vec { + tool_definitions() + } + + fn handles(&self, name: &str) -> bool { + NAMES.contains(&name) + } + + fn describe( + &self, + name: &str, + args: &Value, + ctx: &DescribeCtx<'_>, + ) -> (String, ToolKind, Vec) { + let str_arg = |k: &str| args.get(k).and_then(Value::as_str); + let base = + base_for(ctx.cwd, ctx.roots, str_arg("root")).unwrap_or_else(|_| ctx.cwd.to_path_buf()); + // ACP v1: ToolCallLocation.path must be an absolute path. + let path = str_arg("path").map(|p| absolutize(&resolve_in(&base, p))); + let loc = path + .clone() + .map(ToolCallLocation::new) + .into_iter() + .collect(); + let shown = path + .as_deref() + .map_or_else(|| base.display().to_string(), |p| p.display().to_string()); + match name { + "read_file" => (format!("Read {shown}"), ToolKind::Read, loc), + "write_file" => (format!("Write {shown}"), ToolKind::Edit, loc), + "edit_file" => (format!("Edit {shown}"), ToolKind::Edit, loc), + "list_directory" => (format!("List {shown}"), ToolKind::Search, loc), + "run_command" => { + let cmd = str_arg("command").unwrap_or(""); + let where_ = if base == ctx.cwd { + String::new() + } else { + format!(" in {}", base.display()) + }; + (format!("`{cmd}`{where_}"), ToolKind::Execute, vec![]) + } + _ => (name.to_string(), ToolKind::Other, vec![]), + } + } + + async fn execute( + &self, + ctx: &ToolCtx, + tool_call_id: &str, + name: &str, + args: Value, + ) -> Result { + match name { + "read_file" => ctx.read_file(args).await, + "write_file" => ctx.write_file(tool_call_id, args).await, + "edit_file" => ctx.edit_file(tool_call_id, args).await, + "list_directory" => ctx.list_directory(args).await, + "run_command" => ctx.run_command(tool_call_id, args).await, + other => Err(anyhow!("unknown tool `{other}`")), + } + } +} + +#[derive(Deserialize)] +struct ReadArgs { + path: String, + line: Option, + limit: Option, + root: Option, +} +#[derive(Deserialize)] +struct WriteArgs { + path: String, + content: String, + root: Option, +} +#[derive(Deserialize)] +struct EditArgs { + path: String, + old_string: String, + new_string: String, + root: Option, +} +#[derive(Deserialize)] +struct ListArgs { + path: Option, + root: Option, +} +#[derive(Deserialize)] +struct CommandArgs { + command: String, + root: Option, +} + +impl ToolCtx { + pub(crate) async fn read_file(&self, args: Value) -> Result { + let a: ReadArgs = serde_json::from_value(args)?; + let path = self.resolve_with(a.root.as_deref(), &a.path)?; + let text = if self.caps.fs.read_text_file { + // ACP v1 fs/read_text_file requires an absolute path. + let mut req = ReadTextFileRequest::new(self.session_id.clone(), absolutize(&path)); + if let Some(l) = a.line { + req = req.line(l); + } + if let Some(l) = a.limit { + req = req.limit(l); + } + self.connection + .send_request(req) + .block_task() + .await? + .content + } else { + let full = tokio::fs::read_to_string(&path) + .await + .with_context(|| format!("reading {}", path.display()))?; + let skip = a.line.map_or(0, |l| l.saturating_sub(1) as usize); + let take = a.limit.map_or(usize::MAX, |l| l as usize); + full.lines() + .skip(skip) + .take(take) + .collect::>() + .join("\n") + }; + // Show the user a short summary; give the model the full text. + let mut out = ToolOutcome::ok(format!("{} lines", text.lines().count())); + out.text = truncate(text); + Ok(out) + } + + /// Read the current file contents for diffs/edits; `Ok(None)` if it doesn't exist. + /// A client-fs read failure is an error, distinct from a missing file (ACP + /// `ResourceNotFound`), so callers don't mistake a failed read for a new file. + pub(crate) async fn read_existing(&self, path: &Path) -> Result> { + if self.caps.fs.read_text_file { + let req = ReadTextFileRequest::new(self.session_id.clone(), absolutize(path)); + match self.connection.send_request(req).block_task().await { + Ok(r) => Ok(Some(r.content)), + Err(e) if e.code == ErrorCode::ResourceNotFound => Ok(None), + Err(e) => Err(anyhow!("client read of {} failed: {e}", path.display())), + } + } else { + match tokio::fs::read_to_string(path).await { + Ok(content) => Ok(Some(content)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(anyhow!("reading {}: {e}", path.display())), + } + } + } + + pub(crate) async fn write_text(&self, path: &Path, content: &str) -> Result<()> { + if self.caps.fs.write_text_file { + let req = WriteTextFileRequest::new(self.session_id.clone(), absolutize(path), content); + self.connection.send_request(req).block_task().await?; + } else { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, content).await?; + } + Ok(()) + } + + pub(crate) async fn write_file(&self, id: &str, args: Value) -> Result { + let a: WriteArgs = serde_json::from_value(args)?; + let path = self.resolve_with(a.root.as_deref(), &a.path)?; + let old = self.read_existing(&path).await?; + // ACP v1: Diff.path must be an absolute file path. + let diff = Diff::new(absolutize(&path), a.content.clone()).old_text(old); + self.apply_edit(id, "write_file", &path, &a.content, diff) + .await + } + + pub(crate) async fn edit_file(&self, id: &str, args: Value) -> Result { + let a: EditArgs = serde_json::from_value(args)?; + let path = self.resolve_with(a.root.as_deref(), &a.path)?; + let old = self + .read_existing(&path) + .await? + .ok_or_else(|| anyhow!("{} does not exist", path.display()))?; + match old.matches(&a.old_string).count() { + 0 => bail!("old_string not found in {}", path.display()), + 1 => {} + n => bail!( + "old_string occurs {n} times in {}; make it unique", + path.display() + ), + } + let new = old.replacen(&a.old_string, &a.new_string, 1); + let diff = Diff::new(absolutize(&path), new.clone()).old_text(old); + self.apply_edit(id, "edit_file", &path, &new, diff).await + } + + pub(crate) async fn apply_edit( + &self, + id: &str, + tool: &str, + path: &Path, + content: &str, + diff: Diff, + ) -> Result { + let diff = ToolCallContent::from(diff); + if !self.permit(id, tool, vec![diff.clone()]).await? { + return Ok(ToolOutcome::err("User rejected this change.")); + } + self.write_text(path, content).await?; + Ok(ToolOutcome { + text: format!("Wrote {}", path.display()), + content: vec![diff], + failed: false, + locations: Vec::new(), + }) + } + + pub(crate) async fn list_directory(&self, args: Value) -> Result { + let a: ListArgs = serde_json::from_value(args)?; + let base = self.base_for(a.root.as_deref())?; + let path = a + .path + .map_or_else(|| base.clone(), |p| resolve_in(&base, &p)); + let mut entries = Vec::new(); + let mut rd = tokio::fs::read_dir(&path) + .await + .with_context(|| format!("listing {}", path.display()))?; + while let Some(e) = rd.next_entry().await? { + let mut name = e.file_name().to_string_lossy().into_owned(); + if e.file_type().await.is_ok_and(|t| t.is_dir()) { + name.push('/'); + } + entries.push(name); + } + entries.sort(); + Ok(ToolOutcome::ok(entries.join("\n"))) + } + + pub(crate) async fn run_command(&self, id: &str, args: Value) -> Result { + let a: CommandArgs = serde_json::from_value(args)?; + let base = self.base_for(a.root.as_deref())?; + if !self.permit(id, "run_command", vec![]).await? { + return Ok(ToolOutcome::err("User rejected running this command.")); + } + if self.caps.terminal { + self.run_in_client_terminal(id, &a.command, &base).await + } else { + self.run_locally(&a.command, &base).await + } + } + + pub(crate) async fn run_in_client_terminal( + &self, + id: &str, + command: &str, + cwd: &Path, + ) -> Result { + let sid = self.session_id.clone(); + let req = CreateTerminalRequest::new(sid.clone(), "sh") + .args(vec!["-c".into(), command.into()]) + // ACP v1: the terminal cwd must be an absolute path. + .cwd(absolutize(cwd)) + .output_byte_limit(MAX_OUTPUT_BYTES as u64); + let terminal_id = self + .connection + .send_request(req) + .block_task() + .await? + .terminal_id; + // Embed the live terminal in the tool call so the user can watch it. + let content = vec![ToolCallContent::Terminal(Terminal::new( + terminal_id.clone(), + ))]; + self.update(id, ToolCallUpdateFields::new().content(content.clone()))?; + + let wait = self + .connection + .send_request(WaitForTerminalExitRequest::new( + sid.clone(), + terminal_id.clone(), + )) + .block_task(); + let exit = tokio::select! { + r = wait => Some(r?.exit_status), + () = tokio::time::sleep(self.command_timeout) => None, + () = self.cancel.cancelled() => None, + }; + if exit.is_none() { + let _ = self + .connection + .send_request(KillTerminalRequest::new(sid.clone(), terminal_id.clone())) + .block_task() + .await; + } + let output = self + .connection + .send_request(TerminalOutputRequest::new(sid.clone(), terminal_id.clone())) + .block_task() + .await?; + let _ = self + .connection + .send_request(ReleaseTerminalRequest::new(sid, terminal_id)) + .block_task() + .await; + + let status = match &exit { + Some(s) => match (s.exit_code, &s.signal) { + (Some(c), _) => format!("exit code {c}"), + (None, Some(sig)) => format!("killed by {sig}"), + _ => "exited".into(), + }, + None if self.cancel.is_cancelled() => "cancelled".into(), + None => format!("timed out after {}s", self.command_timeout.as_secs()), + }; + let failed = !matches!(exit.as_ref().and_then(|s| s.exit_code), Some(0)); + let trunc = if output.truncated { + "\n[output truncated]" + } else { + "" + }; + Ok(ToolOutcome { + text: format!("{}{trunc}\n[{status}]", output.output), + content, + failed, + locations: Vec::new(), + }) + } + + pub(crate) async fn run_locally(&self, command: &str, cwd: &Path) -> Result { + let child = tokio::process::Command::new("sh") + .arg("-c") + .arg(command) + .current_dir(cwd) + .stdin(std::process::Stdio::null()) + .kill_on_drop(true) + .output(); + let out = tokio::select! { + r = tokio::time::timeout(self.command_timeout, child) => match r { + Ok(r) => r?, + Err(_) => return Ok(ToolOutcome::err(format!("Command timed out after {}s", self.command_timeout.as_secs()))), + }, + () = self.cancel.cancelled() => return Ok(ToolOutcome::err("Command cancelled")), + }; + let mut text = String::from_utf8_lossy(&out.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&out.stderr)); + let status = out + .status + .code() + .map_or_else(|| "killed by signal".into(), |c| format!("exit code {c}")); + let text = format!("{}\n[{status}]", truncate(text)); + let mut outcome = ToolOutcome::ok(format!("```\n{text}\n```")); + outcome.text = text; + outcome.failed = !out.status.success(); + Ok(outcome) + } +} diff --git a/crates/ed-acp/tests/in_process.rs b/crates/ed-acp/tests/in_process.rs new file mode 100644 index 0000000..d6f0ee5 --- /dev/null +++ b/crates/ed-acp/tests/in_process.rs @@ -0,0 +1,66 @@ +//! `serve` over an in-memory channel: the agent and its client in one process, no stdio. + +use std::sync::Arc; + +use agent_client_protocol::schema::ProtocolVersion; +use agent_client_protocol::schema::v1::InitializeRequest; +use agent_client_protocol::{Agent, Channel, Client, ConnectionTo}; +use ed_acp::{AgentInfo, LlmEnv, Profile, PromptCtx, ServeOptions, Toolset}; + +struct Embedded; + +impl Profile for Embedded { + fn info(&self) -> AgentInfo { + AgentInfo { + name: "embedded-agent", + display_name: "Embedded", + version: "0.0.1", + env: LlmEnv { + prefix: "ED_ACP_IN_PROCESS_TEST", + dir_name: "ed-acp-in-process-test", + default_model: "test-model", + }, + } + } + + fn system_prompt(&self, _ctx: &PromptCtx<'_>) -> String { + String::new() + } + + fn toolsets(&self) -> Vec> { + Vec::new() + } +} + +#[tokio::test] +async fn initialize_over_an_in_memory_channel() { + let (agent_end, client_end) = Channel::duplex(); + let opts = ServeOptions { + yolo: false, + surface: "app", + }; + let server = tokio::spawn(ed_acp::serve(Arc::new(Embedded), opts, agent_end)); + + let info = Client + .builder() + .connect_with(client_end, |conn: ConnectionTo| async move { + conn.send_request(InitializeRequest::new(ProtocolVersion::V1)) + .block_task() + .await + }) + .await + .expect("initialize"); + + let agent = info.agent_info.expect("agentInfo"); + assert_eq!(agent.name, "embedded-agent"); + assert_eq!(agent.version, "0.0.1"); + let caps = info.agent_capabilities; + assert!(caps.load_session); + + // The client hung up when `connect_with` returned, so the server stops on its own. + tokio::time::timeout(std::time::Duration::from_secs(5), server) + .await + .expect("server stops after the client disconnects") + .expect("server task") + .ok(); +} diff --git a/crates/ed-agent-ffi/Cargo.toml b/crates/ed-agent-ffi/Cargo.toml index 0fcee5f..a94715c 100644 --- a/crates/ed-agent-ffi/Cargo.toml +++ b/crates/ed-agent-ffi/Cargo.toml @@ -18,6 +18,7 @@ crate-type = ["lib", "cdylib", "staticlib"] ed-agent.workspace = true onde.workspace = true async-trait.workspace = true +tokio.workspace = true serde_json.workspace = true thiserror.workspace = true uniffi.workspace = true diff --git a/crates/ed-agent-ffi/src/lib.rs b/crates/ed-agent-ffi/src/lib.rs index 230503c..241774c 100644 --- a/crates/ed-agent-ffi/src/lib.rs +++ b/crates/ed-agent-ffi/src/lib.rs @@ -216,6 +216,20 @@ impl From for FfiChatMessage { } } +impl From for ChatMessage { + fn from(value: FfiChatMessage) -> Self { + let role = match value.role { + FfiChatRole::System => ed_agent::ChatRole::System, + FfiChatRole::User => ed_agent::ChatRole::User, + FfiChatRole::Assistant => ed_agent::ChatRole::Assistant, + }; + Self { + role, + content: value.content, + } + } +} + #[derive(Debug, Clone, uniffi::Record)] pub struct FfiSamplingConfig { pub temperature: Option, @@ -333,6 +347,8 @@ pub trait FfiApprovalHandler: Send + Sync { #[uniffi::export(callback_interface)] pub trait FfiEventListener: Send + Sync { fn status_changed(&self, update: FfiStatusUpdate); + /// A piece of the reply as it is generated, during `run`. + fn text_delta(&self, delta: String); fn tool_requested(&self, call: FfiToolCall); fn approval_requested(&self, request: FfiApprovalRequest); fn tool_started(&self, call: FfiToolCall); @@ -341,6 +357,12 @@ pub trait FfiEventListener: Send + Sync { fn warning(&self, message: String); } +/// Receives a streamed reply from `stream_message`. +#[uniffi::export(callback_interface)] +pub trait FfiStreamListener: Send + Sync { + fn on_delta(&self, delta: String); +} + struct ForeignExecutor(Arc); #[async_trait] @@ -378,6 +400,10 @@ impl EventSink for FfiSink { }); } + fn text_delta(&self, delta: &str) { + self.0.text_delta(delta.to_owned()); + } + fn tool_requested(&self, call: &ToolCall) { self.0.tool_requested(call.into()); } @@ -410,20 +436,21 @@ impl EventSink for FfiSink { #[derive(uniffi::Object)] pub struct FfiEdAgent { inner: Ed, + /// Ends the stream in progress, if any. + stream_cancel: std::sync::Mutex>>, } -#[uniffi::export(async_runtime = "tokio")] impl FfiEdAgent { - #[uniffi::constructor] - pub fn new( + fn build( executor: Box, approvals: Box, events: Box, config: Option, + app_id: Option, ) -> Arc { let config = config.unwrap_or_default(); Arc::new(Self { - inner: Ed::with_agent( + inner: Ed::with_agent_and_app_id( FfiSink(Arc::from(events)), Arc::new(ForeignExecutor(Arc::from(executor))), Arc::new(ForeignApprovals(Arc::from(approvals))), @@ -432,9 +459,36 @@ impl FfiEdAgent { max_tool_output_chars: usize::try_from(config.max_tool_output_chars) .unwrap_or(usize::MAX), }, + app_id, ), + stream_cancel: std::sync::Mutex::new(None), }) } +} + +#[uniffi::export(async_runtime = "tokio")] +impl FfiEdAgent { + #[uniffi::constructor] + pub fn new( + executor: Box, + approvals: Box, + events: Box, + config: Option, + ) -> Arc { + Self::build(executor, approvals, events, config, None) + } + + /// Like `new`, with an Onde app id so the engine reports usage under it. + #[uniffi::constructor] + pub fn new_with_app_id( + executor: Box, + approvals: Box, + events: Box, + config: Option, + app_id: Option, + ) -> Arc { + Self::build(executor, approvals, events, config, app_id) + } pub async fn register_tool(&self, tool: FfiToolDefinition) -> Result<(), FfiEdError> { self.inner @@ -556,6 +610,85 @@ impl FfiEdAgent { } } + /// Stream the reply to `message` into `listener`, for plain chat without tools. + /// Returns when the reply is complete, on error, or after `cancel_stream`. + /// The turn so far stays in history either way. + pub async fn stream_message( + &self, + message: String, + listener: Box, + ) -> Result<(), FfiEdError> { + let mut stream = self + .inner + .stream(message) + .await + .map_err(|error| FfiEdError::Failure { + reason: error.to_string(), + })?; + let (cancel, mut cancelled) = tokio::sync::oneshot::channel(); + *self.stream_cancel.lock().unwrap_or_else(|e| e.into_inner()) = Some(cancel); + loop { + tokio::select! { + next = stream.next() => match next { + Some(Ok(delta)) => listener.on_delta(delta), + Some(Err(error)) => { + return Err(FfiEdError::Failure { reason: error.to_string() }); + } + None => return Ok(()), + }, + _ = &mut cancelled => return Ok(()), + } + } + } + + /// Stop the `stream_message` in progress. Does nothing when none is. + pub fn cancel_stream(&self) { + if let Some(cancel) = self + .stream_cancel + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + { + let _ = cancel.send(()); + } + } + + /// One-shot generation over `messages`. Leaves the conversation untouched. + pub async fn generate( + &self, + messages: Vec, + sampling: Option, + ) -> Result { + self.inner + .generate( + messages.into_iter().map(Into::into).collect(), + sampling.map(Into::into), + ) + .await + .map_err(|error| FfiEdError::Failure { + reason: error.to_string(), + }) + } + + /// Replace the conversation with `messages`. Needs a loaded model. + pub async fn restore_history(&self, messages: Vec) { + self.inner + .restore_history(messages.into_iter().map(Into::into).collect()) + .await; + } + + pub async fn set_system_prompt(&self, prompt: String) { + self.inner.set_system_prompt(prompt).await; + } + + pub async fn clear_system_prompt(&self) { + self.inner.clear_system_prompt().await; + } + + pub async fn set_sampling(&self, sampling: FfiSamplingConfig) { + self.inner.set_sampling(sampling.into()).await; + } + pub fn cancel(&self) { self.inner.cancel(); } diff --git a/crates/ed-agent-tauri/Cargo.toml b/crates/ed-agent-tauri/Cargo.toml index 37e598d..772766d 100644 --- a/crates/ed-agent-tauri/Cargo.toml +++ b/crates/ed-agent-tauri/Cargo.toml @@ -16,3 +16,10 @@ ed-agent.workspace = true tauri.workspace = true serde.workspace = true log.workspace = true +async-trait.workspace = true +serde_json.workspace = true +uuid.workspace = true +tokio = { workspace = true, features = ["time"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["time", "macros", "rt"] } diff --git a/crates/ed-agent-tauri/LICENSE-APACHE b/crates/ed-agent-tauri/LICENSE-APACHE index 58885ec..bdc140a 100644 --- a/crates/ed-agent-tauri/LICENSE-APACHE +++ b/crates/ed-agent-tauri/LICENSE-APACHE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Splitfire AB (Onde Inference) + Copyright 2026 Onde Inference Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/crates/ed-agent-tauri/LICENSE-MIT b/crates/ed-agent-tauri/LICENSE-MIT index 1b62ae8..4b6964c 100644 --- a/crates/ed-agent-tauri/LICENSE-MIT +++ b/crates/ed-agent-tauri/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Splitfire AB (Onde Inference) +Copyright (c) 2026 Onde Inference Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/crates/ed-agent-tauri/README.md b/crates/ed-agent-tauri/README.md index e53024a..ffcc2fd 100644 --- a/crates/ed-agent-tauri/README.md +++ b/crates/ed-agent-tauri/README.md @@ -23,18 +23,28 @@ These events reach the webview: `chat_status_changed`, `chat_reply`, `chat_tool_requested`, `chat_approval_requested`, `chat_tool_started`, `chat_tool_finished`, `chat_agent_reply`, and `chat_warning`. +## Several conversations + +`EdSessions` is the multi-session form. Give it a `SessionStore` and it handles +switching, the in-memory cache, logout (`reset`) and approvals. Register +`chat_list_sessions`, `chat_new_session`, `chat_switch_session`, +`chat_delete_session`, `chat_get_session_history`, `chat_submit` and +`chat_respond_approval`. + +`chat_submit` returns immediately; the answer is a `chat_reply` event +`{id, session, reply, duration, error}`, preceded by `chat_text_delta +{session, id, delta}` events. Approvals use `chat_approval_requested +{request_id, session, call, risk}` and `chat_approval_resolved {request_id}`; +answer with `chat_respond_approval`. To rewrite the message first, wrap +`EdSessions::submit_with` in your own command. + Loading a model is deliberately not a command here — which model, and from where, is an application decision. Call `Ed::load` or `Ed::load_with` from your own command. -Tool calling needs both halves from you. Build the agent with `Ed::with_agent` -to pass a `ToolExecutor` and an `ApprovalHandler`; `Ed::with_sink` rejects every -tool and denies every approval. Approvals are not a command either: answering -one means a webview round trip inside an awaited Rust call, and hosts differ -enough about how to wire that up that this crate emits -`chat_approval_requested` and leaves the answering to you. +Without `EdSessions`, tool calling needs both halves from you: build the agent with `Ed::with_agent` to pass a `ToolExecutor` and an `ApprovalHandler`. ## Copyright -© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)). +© 2026 [Onde Inference](https://ondeinference.com). Licensed under MIT or Apache-2.0. diff --git a/crates/ed-agent-tauri/src/approvals.rs b/crates/ed-agent-tauri/src/approvals.rs new file mode 100644 index 0000000..efbf247 --- /dev/null +++ b/crates/ed-agent-tauri/src/approvals.rs @@ -0,0 +1,208 @@ +//! Approvals answered by the webview. + +use std::collections::HashMap; +use std::time::Duration; + +use async_trait::async_trait; +use ed_agent::{ApprovalDecision, ApprovalHandler, ApprovalRequest}; +use log::{error, warn}; +use serde::Serialize; +use tauri::{AppHandle, Emitter}; +use tokio::sync::{oneshot, Mutex}; + +use crate::events::{ + ApprovalRequestedPayload, ApprovalResolvedPayload, EVENT_CHAT_APPROVAL_REQUESTED, + EVENT_CHAT_APPROVAL_RESOLVED, +}; +use crate::scope::TurnScope; + +/// How long to wait for the user before denying. +pub const DEFAULT_APPROVAL_TIMEOUT: Duration = Duration::from_secs(120); + +/// An [`ApprovalHandler`] that asks the webview. +/// +/// For each request it emits `chat_approval_requested`, waits for the webview +/// to call `chat_respond_approval` (or [`respond`](TauriApprovals::respond)), +/// and emits `chat_approval_resolved` when the wait ends, however it ends. +/// A timeout, a closed window or [`cancel_all`](TauriApprovals::cancel_all) +/// all mean *deny*. +type Emit = Box Result<(), String> + Send + Sync>; + +pub struct TauriApprovals { + emit: Emit, + scope: TurnScope, + timeout: Duration, + pending: Mutex>>, +} + +impl TauriApprovals { + pub fn new(app: AppHandle, timeout: Duration) -> Self { + Self::scoped(app, TurnScope::default(), timeout) + } + + pub(crate) fn scoped(app: AppHandle, scope: TurnScope, timeout: Duration) -> Self { + let emit: Emit = + Box::new(move |event, payload| app.emit(event, payload).map_err(|err| err.to_string())); + Self::with_emitter(emit, scope, timeout) + } + + fn with_emitter(emit: Emit, scope: TurnScope, timeout: Duration) -> Self { + Self { + emit, + scope, + timeout, + pending: Mutex::new(HashMap::new()), + } + } + + fn emit(&self, event: &str, payload: impl Serialize) -> Result<(), String> { + let value = serde_json::to_value(payload).map_err(|err| err.to_string())?; + (self.emit)(event, value) + } + + /// Answer a pending request. Errors if it already timed out or was + /// answered. + pub async fn respond( + &self, + request_id: &str, + decision: ApprovalDecision, + ) -> Result<(), String> { + let gone = || "This approval request is no longer pending.".to_string(); + let sender = self + .pending + .lock() + .await + .remove(request_id) + .ok_or_else(gone)?; + sender.send(decision).map_err(|_| gone()) + } + + /// Deny everything that is waiting. Use it when the conversation the + /// requests belong to goes away. + pub async fn cancel_all(&self) { + let pending: Vec<_> = self.pending.lock().await.drain().collect(); + for (_, sender) in pending { + // Each waiting `approve` call emits its own resolved event once it + // sees the denial, so every request closes exactly once. + let _ = sender.send(ApprovalDecision::Deny); + } + } +} + +#[async_trait] +impl ApprovalHandler for TauriApprovals { + async fn approve(&self, request: ApprovalRequest) -> ApprovalDecision { + let request_id = uuid::Uuid::new_v4().to_string(); + let (sender, receiver) = oneshot::channel(); + self.pending.lock().await.insert(request_id.clone(), sender); + + let (session, _) = self.scope.get(); + let payload = ApprovalRequestedPayload { + request_id: request_id.clone(), + session, + call: request.call, + risk: request.risk, + }; + if let Err(err) = self.emit(EVENT_CHAT_APPROVAL_REQUESTED, payload) { + self.pending.lock().await.remove(&request_id); + error!("ed-tauri: could not request approval: {err}"); + return ApprovalDecision::Deny; + } + + let decision = match tokio::time::timeout(self.timeout, receiver).await { + Ok(Ok(decision)) => decision, + Ok(Err(_)) => ApprovalDecision::Deny, + Err(_) => { + warn!("ed-tauri: approval {request_id} timed out; denying"); + ApprovalDecision::Deny + } + }; + self.pending.lock().await.remove(&request_id); + if let Err(err) = self.emit( + EVENT_CHAT_APPROVAL_RESOLVED, + ApprovalResolvedPayload { request_id }, + ) { + error!("ed-tauri: could not close approval: {err}"); + } + decision + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use ed_agent::{ToolCall, ToolRisk}; + + use super::*; + + fn request() -> ApprovalRequest { + ApprovalRequest { + call: ToolCall { + id: "1".into(), + name: "send_message".into(), + arguments: "{}".into(), + }, + risk: ToolRisk::Mutating, + } + } + + fn approvals(timeout: Duration) -> Arc { + Arc::new(TauriApprovals::with_emitter( + Box::new(|_, _| Ok(())), + TurnScope::default(), + timeout, + )) + } + + async fn pending_id(approvals: &TauriApprovals) -> String { + loop { + if let Some(id) = approvals.pending.lock().await.keys().next().cloned() { + return id; + } + tokio::task::yield_now().await; + } + } + + #[tokio::test] + async fn the_webview_can_allow() { + let approvals = approvals(Duration::from_secs(5)); + let waiting = tokio::spawn({ + let approvals = approvals.clone(); + async move { approvals.approve(request()).await } + }); + let id = pending_id(&approvals).await; + + approvals + .respond(&id, ApprovalDecision::AllowForSession) + .await + .unwrap(); + + assert_eq!(waiting.await.unwrap(), ApprovalDecision::AllowForSession); + assert!(approvals + .respond(&id, ApprovalDecision::Deny) + .await + .is_err()); + } + + #[tokio::test] + async fn cancel_all_denies_everything_pending() { + let approvals = approvals(Duration::from_secs(5)); + let waiting = tokio::spawn({ + let approvals = approvals.clone(); + async move { approvals.approve(request()).await } + }); + pending_id(&approvals).await; + + approvals.cancel_all().await; + + assert_eq!(waiting.await.unwrap(), ApprovalDecision::Deny); + } + + #[tokio::test] + async fn an_unanswered_request_times_out_as_a_denial() { + let approvals = approvals(Duration::from_millis(20)); + assert_eq!(approvals.approve(request()).await, ApprovalDecision::Deny); + assert!(approvals.pending.lock().await.is_empty()); + } +} diff --git a/crates/ed-agent-tauri/src/commands.rs b/crates/ed-agent-tauri/src/commands.rs index fe8f726..4d865f6 100644 --- a/crates/ed-agent-tauri/src/commands.rs +++ b/crates/ed-agent-tauri/src/commands.rs @@ -1,8 +1,11 @@ //! The commands a webview invokes. -use ed_agent::{AgentReply, ChatMessage, ChatReply, Ed, EngineInfo}; +use std::sync::Arc; + +use ed_agent::{AgentReply, ApprovalDecision, ChatMessage, ChatReply, Ed, EngineInfo, SessionMeta}; use tauri::State; +use crate::sessions::EdSessions; use crate::sink::TauriSink; /// Managed state holding the agent. @@ -12,12 +15,12 @@ use crate::sink::TauriSink; /// runs more than one window against separate engines, needs to control the /// lifetime, and a `static` takes that away. pub struct EdState { - ed: Ed, + ed: Arc>, } impl EdState { pub fn new(ed: Ed) -> Self { - Self { ed } + Self { ed: Arc::new(ed) } } /// The agent, for commands the host defines itself — loading a model, @@ -26,6 +29,44 @@ impl EdState { pub fn ed(&self) -> &Ed { &self.ed } + + /// A shared handle to the agent, for work moved onto a spawned task. + pub fn shared(&self) -> Arc> { + self.ed.clone() + } + + /// Run an agent turn on a spawned task and deliver the result as a + /// `chat_reply` event `{id, session, reply, duration, error}`, with + /// `session` empty. Returns immediately. + /// + /// For single-conversation apps. A long-pending invoke can be dropped by a + /// mobile webview, so the answer travels by event. Text deltas arrive as + /// `chat_text_delta` while it runs. + pub fn submit(&self, app: tauri::AppHandle, id: String, message: String) { + use tauri::Emitter; + let ed = self.ed.clone(); + tokio::spawn(async move { + let payload = match ed.run(message).await { + Ok(reply) => crate::SubmitReplyPayload { + id, + session: String::new(), + reply: Some(reply.text), + duration: Some(reply.duration), + error: None, + }, + Err(err) => crate::SubmitReplyPayload { + id, + session: String::new(), + reply: None, + duration: None, + error: Some(err.to_string()), + }, + }; + if let Err(err) = app.emit(crate::EVENT_CHAT_REPLY, payload) { + log::warn!("ed-tauri: could not emit chat_reply: {err}"); + } + }); + } } /// Current status, loaded model, footprint, and history length. @@ -88,3 +129,64 @@ pub async fn chat_cancel(state: State<'_, EdState>) -> Result<(), String> { state.ed.cancel(); Ok(()) } + +/// Stored sessions, most recently updated first. +#[tauri::command] +pub async fn chat_list_sessions(state: State<'_, EdSessions>) -> Result, String> { + state.list().await.map_err(|e| e.to_string()) +} + +/// Start an empty session and make it live. Returns its id. +#[tauri::command] +pub async fn chat_new_session(state: State<'_, EdSessions>) -> Result { + Ok(state.new_session().await) +} + +/// Make `session` live and return its messages. +#[tauri::command] +pub async fn chat_switch_session( + state: State<'_, EdSessions>, + session: String, +) -> Result, String> { + Ok(state.history(&session).await) +} + +#[tauri::command] +pub async fn chat_delete_session( + state: State<'_, EdSessions>, + session: String, +) -> Result<(), String> { + state.delete(&session).await.map_err(|e| e.to_string()) +} + +/// The messages of `session`, without switching to it. +#[tauri::command] +pub async fn chat_get_session_history( + state: State<'_, EdSessions>, + session: String, +) -> Result, String> { + Ok(state.peek(&session).await) +} + +/// Run a turn in `session`. Returns at once; the result is a `chat_reply` +/// event carrying `id`. +#[tauri::command] +pub async fn chat_submit( + state: State<'_, EdSessions>, + session: String, + id: String, + message: String, +) -> Result<(), String> { + state.submit(&session, &id, message).await; + Ok(()) +} + +/// Answer a `chat_approval_requested` event. +#[tauri::command] +pub async fn chat_respond_approval( + state: State<'_, EdSessions>, + request_id: String, + decision: ApprovalDecision, +) -> Result<(), String> { + state.respond_approval(&request_id, decision).await +} diff --git a/crates/ed-agent-tauri/src/events.rs b/crates/ed-agent-tauri/src/events.rs index 01968fd..cdca303 100644 --- a/crates/ed-agent-tauri/src/events.rs +++ b/crates/ed-agent-tauri/src/events.rs @@ -45,6 +45,56 @@ pub const EVENT_CHAT_AGENT_REPLY: &str = "chat_agent_reply"; /// is still viable, such as an unverified model or a spent tool-round budget. pub const EVENT_CHAT_WARNING: &str = "chat_warning"; +/// Emitted with a [`TextDeltaPayload`] for each piece of a reply as it is +/// generated. Concatenate the deltas of one `id` to grow the assistant bubble; +/// [`EVENT_CHAT_REPLY`] still arrives at the end with the whole text. +pub const EVENT_CHAT_TEXT_DELTA: &str = "chat_text_delta"; + +/// Emitted with an [`ApprovalResolvedPayload`] once an approval request has +/// been answered, timed out, or cancelled, so the webview can close its sheet. +pub const EVENT_CHAT_APPROVAL_RESOLVED: &str = "chat_approval_resolved"; + +/// Payload for [`EVENT_CHAT_TEXT_DELTA`]. +/// +/// `session` and `id` are empty when the turn didn't come through a `submit` +/// call, which is the only thing that knows them. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TextDeltaPayload { + pub session: String, + pub id: String, + pub delta: String, +} + +/// Payload for [`EVENT_CHAT_REPLY`] when the turn came through `submit`. +/// +/// A superset of [`ChatReply`](ed_agent::ChatReply), so a listener written for +/// the single-session event keeps working. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubmitReplyPayload { + pub id: String, + pub session: String, + pub reply: Option, + pub duration: Option, + pub error: Option, +} + +/// Payload for [`EVENT_CHAT_APPROVAL_REQUESTED`] when the approval handler is +/// [`TauriApprovals`](crate::TauriApprovals). Answer it with +/// `chat_respond_approval { request_id, decision }`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalRequestedPayload { + pub request_id: String, + pub session: String, + pub call: ed_agent::ToolCall, + pub risk: ed_agent::ToolRisk, +} + +/// Payload for [`EVENT_CHAT_APPROVAL_RESOLVED`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalResolvedPayload { + pub request_id: String, +} + /// Payload for [`EVENT_CHAT_STATUS_CHANGED`]. /// /// `status` serialises to `"unloaded"`, `"loading"`, `"ready"`, `"generating"`, diff --git a/crates/ed-agent-tauri/src/lib.rs b/crates/ed-agent-tauri/src/lib.rs index b68bd8b..1a64a48 100644 --- a/crates/ed-agent-tauri/src/lib.rs +++ b/crates/ed-agent-tauri/src/lib.rs @@ -43,21 +43,41 @@ //! where, is an application decision. Call [`Ed::load`](ed_agent::Ed::load) //! from your own command or during setup. //! +//! # Several conversations +//! +//! For an app with a session list, build an [`EdSessions`] instead of an +//! [`EdState`]. You supply a [`SessionStore`](ed_agent::SessionStore); it owns +//! the live session, the cache, the logout fence and approvals: +//! +//! ```text +//! let sessions = EdSessions::builder(app.handle().clone(), MyStore::new()) +//! .executor(my_executor) +//! .build(); +//! app.manage(sessions); +//! ``` +//! +//! Register `chat_list_sessions`, `chat_new_session`, `chat_switch_session`, +//! `chat_delete_session`, `chat_get_session_history`, `chat_submit` and +//! `chat_respond_approval`. `chat_submit` returns at once and the answer comes +//! as a `chat_reply` event `{id, session, reply, duration, error}`, with +//! `chat_text_delta {session, id, delta}` events while it generates. +//! +//! A host that has to rewrite the message first (to add context) or recover +//! from an engine failure wraps [`EdSessions::submit_with`] in a command of its +//! own. Call [`EdSessions::reset`] on logout or account switch. +//! //! # Tools //! -//! Tool calling works through this crate, but the host owns both halves of it. -//! Build the agent with [`Ed::with_agent`](ed_agent::Ed::with_agent) to supply -//! a [`ToolExecutor`](ed_agent::ToolExecutor) and an -//! [`ApprovalHandler`](ed_agent::ApprovalHandler); the default `Ed::with_sink` -//! rejects every tool and denies every approval, which is the right default but -//! not a working agent. +//! Tool calling works through this crate, but the host supplies the executor. +//! With [`EdSessions`] approvals are handled for you: [`TauriApprovals`] emits +//! `chat_approval_requested {request_id, session, call, risk}`, waits (120 s by +//! default) for `chat_respond_approval {request_id, decision}`, then emits +//! `chat_approval_resolved {request_id}`. A timeout or a reset denies. //! -//! Approvals are deliberately not a command. Answering one means a webview -//! round trip in the middle of an awaited Rust call, and how a host wants to -//! wire that up (a channel, a shared map, a modal it blocks on) differs enough -//! that guessing here would be worse than leaving it. What this crate does give -//! you is [`EVENT_CHAT_APPROVAL_REQUESTED`], so the sheet can go up at the -//! right moment. +//! With [`EdState`], build the agent with +//! [`Ed::with_agent`](ed_agent::Ed::with_agent) and your own +//! [`ApprovalHandler`](ed_agent::ApprovalHandler); the default `Ed::with_sink` +//! rejects every tool and denies every approval. //! //! # Events //! @@ -74,20 +94,30 @@ //! [`ToolExecutionResult`](ed_agent::ToolExecutionResult) //! - [`EVENT_CHAT_AGENT_REPLY`] with an [`AgentReply`](ed_agent::AgentReply) //! - [`EVENT_CHAT_WARNING`] with a string +//! - [`EVENT_CHAT_TEXT_DELTA`] with a [`TextDeltaPayload`] +//! - [`EVENT_CHAT_APPROVAL_RESOLVED`] with an [`ApprovalResolvedPayload`] #![forbid(unsafe_code)] +mod approvals; mod commands; mod events; +mod scope; +mod sessions; mod sink; +pub use approvals::{TauriApprovals, DEFAULT_APPROVAL_TIMEOUT}; pub use commands::{ - chat_cancel, chat_clear_history, chat_get_history, chat_get_status, chat_run, - chat_send_message, EdState, + chat_cancel, chat_clear_history, chat_delete_session, chat_get_history, + chat_get_session_history, chat_get_status, chat_list_sessions, chat_new_session, + chat_respond_approval, chat_run, chat_send_message, chat_submit, chat_switch_session, EdState, }; pub use events::{ - ChatStatusPayload, EVENT_CHAT_AGENT_REPLY, EVENT_CHAT_APPROVAL_REQUESTED, EVENT_CHAT_REPLY, - EVENT_CHAT_STATUS_CHANGED, EVENT_CHAT_TOOL_FINISHED, EVENT_CHAT_TOOL_REQUESTED, + ApprovalRequestedPayload, ApprovalResolvedPayload, ChatStatusPayload, SubmitReplyPayload, + TextDeltaPayload, EVENT_CHAT_AGENT_REPLY, EVENT_CHAT_APPROVAL_REQUESTED, + EVENT_CHAT_APPROVAL_RESOLVED, EVENT_CHAT_REPLY, EVENT_CHAT_STATUS_CHANGED, + EVENT_CHAT_TEXT_DELTA, EVENT_CHAT_TOOL_FINISHED, EVENT_CHAT_TOOL_REQUESTED, EVENT_CHAT_TOOL_STARTED, EVENT_CHAT_WARNING, }; +pub use sessions::{EdSessions, EdSessionsBuilder}; pub use sink::TauriSink; diff --git a/crates/ed-agent-tauri/src/scope.rs b/crates/ed-agent-tauri/src/scope.rs new file mode 100644 index 0000000..0083f9e --- /dev/null +++ b/crates/ed-agent-tauri/src/scope.rs @@ -0,0 +1,31 @@ +//! Which session and message a running turn belongs to. + +use std::sync::{Arc, Mutex}; + +/// Shared between the sink and the approval handler so that events emitted +/// from inside Ed (which knows nothing about sessions) can still say which +/// session they belong to. +/// +/// Ed runs one turn at a time, so one slot is enough. +#[derive(Clone, Default)] +pub(crate) struct TurnScope(Arc>>); + +impl TurnScope { + pub(crate) fn set(&self, session: &str, id: &str) { + *self.0.lock().unwrap_or_else(|e| e.into_inner()) = + Some((session.to_owned(), id.to_owned())); + } + + pub(crate) fn clear(&self) { + *self.0.lock().unwrap_or_else(|e| e.into_inner()) = None; + } + + /// `(session, id)`, both empty outside a `submit`. + pub(crate) fn get(&self) -> (String, String) { + self.0 + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + .unwrap_or_default() + } +} diff --git a/crates/ed-agent-tauri/src/sessions.rs b/crates/ed-agent-tauri/src/sessions.rs new file mode 100644 index 0000000..c1b4b18 --- /dev/null +++ b/crates/ed-agent-tauri/src/sessions.rs @@ -0,0 +1,368 @@ +//! Several conversations over one Ed. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use ed_agent::{ + AgentConfig, AgentError, AgentReply, ApprovalDecision, ChatMessage, Ed, RejectingExecutor, + SessionError, SessionMeta, SessionStore, ToolExecutor, +}; +use log::{error, info, warn}; +use tauri::{AppHandle, Emitter}; +use tokio::sync::Mutex; + +use crate::approvals::{TauriApprovals, DEFAULT_APPROVAL_TIMEOUT}; +use crate::events::{SubmitReplyPayload, EVENT_CHAT_REPLY}; +use crate::scope::TurnScope; +use crate::sink::TauriSink; + +#[derive(Default)] +struct State { + active: Option, + /// Histories of sessions that were switched away from, so switching back + /// doesn't hit the store. + cache: HashMap>, +} + +struct Shared { + app: AppHandle, + ed: Arc>, + store: Arc, + approvals: Arc, + scope: TurnScope, + /// Bumped by [`EdSessions::reset`]. A turn captures it at dispatch and + /// refuses to save or emit if it changed by completion, so a reply that + /// finishes after logout or an account switch can't land in the next + /// account's storage. + generation: AtomicU64, + state: Mutex, + error_message: Option, + new_id: Box String + Send + Sync>, +} + +/// Multi-session chat state: one [`Ed`], many conversations. +/// +/// Register it with `app.manage(sessions)` and the `chat_*_session` commands. +/// It owns which session is live in the engine, an in-memory cache, the +/// generation fence, and approvals; persistence stays with the host's +/// [`SessionStore`]. +/// +/// Cheap to clone. +#[derive(Clone)] +pub struct EdSessions { + shared: Arc, +} + +/// Configuration for [`EdSessions`]. +pub struct EdSessionsBuilder { + app: AppHandle, + store: Arc, + executor: Arc, + config: AgentConfig, + onde_app_id: Option, + approval_timeout: Duration, + error_message: Option, + new_id: Box String + Send + Sync>, +} + +impl EdSessionsBuilder { + /// Run tools through `executor`. Defaults to rejecting every tool. + pub fn executor(mut self, executor: Arc) -> Self { + self.executor = executor; + self + } + + pub fn config(mut self, config: AgentConfig) -> Self { + self.config = config; + self + } + + /// Associate telemetry with an Onde app id. + pub fn onde_app_id(mut self, id: impl Into) -> Self { + self.onde_app_id = Some(id.into()); + self + } + + /// How long an approval waits for the user. Default 120 seconds. + pub fn approval_timeout(mut self, timeout: Duration) -> Self { + self.approval_timeout = timeout; + self + } + + /// Show this to the user instead of the error text when a turn fails. The + /// real error is still logged. + pub fn error_message(mut self, message: impl Into) -> Self { + self.error_message = Some(message.into()); + self + } + + /// How [`EdSessions::new_session`] names a session. Defaults to a random UUID. A store that + /// treats some ids as ephemeral (by prefix, say) can mint matching ones here. + pub fn id_generator(mut self, generator: impl Fn() -> String + Send + Sync + 'static) -> Self { + self.new_id = Box::new(generator); + self + } + + pub fn build(self) -> EdSessions { + let scope = TurnScope::default(); + let approvals = Arc::new(TauriApprovals::scoped( + self.app.clone(), + scope.clone(), + self.approval_timeout, + )); + let sink = TauriSink::scoped(self.app.clone(), scope.clone(), false); + let ed = Ed::with_agent_and_app_id( + sink, + self.executor, + approvals.clone(), + self.config, + self.onde_app_id, + ); + EdSessions { + shared: Arc::new(Shared { + app: self.app, + ed: Arc::new(ed), + store: self.store, + approvals, + scope, + generation: AtomicU64::new(0), + state: Mutex::new(State::default()), + error_message: self.error_message, + new_id: self.new_id, + }), + } + } +} + +impl EdSessions { + pub fn builder(app: AppHandle, store: impl SessionStore) -> EdSessionsBuilder { + EdSessionsBuilder { + app, + store: Arc::new(store), + executor: Arc::new(RejectingExecutor), + config: AgentConfig::default(), + onde_app_id: None, + approval_timeout: DEFAULT_APPROVAL_TIMEOUT, + error_message: None, + new_id: Box::new(|| uuid::Uuid::new_v4().to_string()), + } + } + + /// The agent, for loading a model and anything else Ed exposes. + pub fn ed(&self) -> &Arc> { + &self.shared.ed + } + + pub fn approvals(&self) -> &Arc { + &self.shared.approvals + } + + /// The current generation. Capture it before slow work and compare with + /// [`is_current`](EdSessions::is_current) afterwards. + pub fn generation(&self) -> u64 { + self.shared.generation.load(Ordering::SeqCst) + } + + pub fn is_current(&self, generation: u64) -> bool { + self.generation() == generation + } + + /// The id of the session live in the engine, if any. + pub async fn active(&self) -> Option { + self.shared.state.lock().await.active.clone() + } + + /// Stored sessions, most recently updated first. + pub async fn list(&self) -> Result, SessionError> { + self.shared.store.list().await + } + + /// Make `session` the live conversation. + /// + /// The outgoing session's history is kept in memory; the incoming one + /// comes from memory, then the store. An unknown id starts empty. + pub async fn activate(&self, session: &str) { + let shared = &self.shared; + let mut state = shared.state.lock().await; + if state.active.as_deref() == Some(session) { + return; + } + if let Some(previous) = state.active.take() { + let live = shared.ed.history().await; + state.cache.insert(previous, live); + } + let stored = match state.cache.remove(session) { + Some(messages) => messages, + None => match shared.store.load(session).await { + Ok(messages) => messages.unwrap_or_default(), + Err(err) => { + error!("ed-tauri: could not load session {session}: {err}"); + Vec::new() + } + }, + }; + shared.ed.restore_history(stored).await; + state.active = Some(session.to_owned()); + } + + /// Start an empty session and make it live. Returns its id. The store + /// hears about it when the first turn completes. + pub async fn new_session(&self) -> String { + let id = (self.shared.new_id)(); + self.activate(&id).await; + id + } + + /// The messages of `session`, activating it. + pub async fn history(&self, session: &str) -> Vec { + self.activate(session).await; + self.shared.ed.history().await + } + + /// The messages of `session` without switching to it: the live history if it is the active + /// session, else the cache, else the store. For rendering a session that is not on screen, + /// without disturbing a turn that is running in another. + pub async fn peek(&self, session: &str) -> Vec { + let shared = &self.shared; + let state = shared.state.lock().await; + if state.active.as_deref() == Some(session) { + return shared.ed.history().await; + } + if let Some(cached) = state.cache.get(session) { + return cached.clone(); + } + drop(state); + match shared.store.load(session).await { + Ok(messages) => messages.unwrap_or_default(), + Err(err) => { + error!("ed-tauri: could not load session {session}: {err}"); + Vec::new() + } + } + } + + /// Delete a session from the store and memory. If it was live, the engine + /// is cleared and no session is live until the next activate. + pub async fn delete(&self, session: &str) -> Result<(), SessionError> { + let shared = &self.shared; + let result = shared.store.delete(session).await; + let mut state = shared.state.lock().await; + state.cache.remove(session); + if state.active.as_deref() == Some(session) { + state.active = None; + shared.ed.clear_history().await; + } + result + } + + /// Drop all in-memory state and fence off in-flight turns. For logout and + /// account switches: the next account must never see this one's chats. + /// Stored sessions are left alone. + pub async fn reset(&self) { + let shared = &self.shared; + shared.generation.fetch_add(1, Ordering::SeqCst); + shared.ed.cancel(); + shared.approvals.cancel_all().await; + let mut state = shared.state.lock().await; + state.cache.clear(); + state.active = None; + shared.ed.clear_history().await; + } + + /// Save the live history under `session`, unless `generation` is stale. + pub async fn save(&self, session: &str, generation: u64) { + let shared = &self.shared; + let live = shared.ed.history().await; + let mut state = shared.state.lock().await; + if !self.is_current(generation) { + return; + } + if let Err(err) = shared.store.save(session, &live).await { + error!("ed-tauri: could not save session {session}: {err}"); + } + state.cache.insert(session.to_owned(), live); + } + + /// Run one agent turn in `session` and deliver the result as a + /// `chat_reply` event. + /// + /// Returns as soon as the session is active and the turn is spawned. A + /// long-pending invoke can be dropped by a mobile webview, so the answer + /// travels by event: `{id, session, reply, duration, error}`. + pub async fn submit(&self, session: &str, id: &str, message: impl Into) { + let message = message.into(); + self.submit_with(session, id, move |ed| async move { ed.run(message).await }) + .await; + } + + /// Like [`submit`](EdSessions::submit), with the turn supplied by the + /// host: rewrite the message, or recover and retry on a known engine + /// failure. The closure runs on the spawned task. + pub async fn submit_with(&self, session: &str, id: &str, turn: F) + where + F: FnOnce(Arc>) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + self.activate(session).await; + let generation = self.generation(); + let this = self.clone(); + let session = session.to_owned(); + let id = id.to_owned(); + info!("ed-tauri: dispatch session={session} id={id}"); + + tokio::spawn(async move { + this.shared.scope.set(&session, &id); + let result = turn(this.shared.ed.clone()).await; + this.shared.scope.clear(); + + if !this.is_current(generation) { + info!("ed-tauri: reply dropped, the account context changed (session={session})"); + return; + } + this.save(&session, generation).await; + if !this.is_current(generation) { + return; + } + + let payload = match result { + Ok(reply) => SubmitReplyPayload { + id, + session, + reply: Some(reply.text), + duration: Some(reply.duration), + error: None, + }, + Err(err) => { + error!("ed-tauri: turn failed: {err}"); + SubmitReplyPayload { + id, + session, + reply: None, + duration: None, + error: Some( + this.shared + .error_message + .clone() + .unwrap_or_else(|| err.to_string()), + ), + } + } + }; + if let Err(err) = this.shared.app.emit(EVENT_CHAT_REPLY, payload) { + warn!("ed-tauri: could not emit chat_reply: {err}"); + } + }); + } + + /// Answer a pending approval. + pub async fn respond_approval( + &self, + request_id: &str, + decision: ApprovalDecision, + ) -> Result<(), String> { + self.shared.approvals.respond(request_id, decision).await + } +} diff --git a/crates/ed-agent-tauri/src/sink.rs b/crates/ed-agent-tauri/src/sink.rs index b4a3f0e..b267986 100644 --- a/crates/ed-agent-tauri/src/sink.rs +++ b/crates/ed-agent-tauri/src/sink.rs @@ -8,10 +8,11 @@ use serde::Serialize; use tauri::{AppHandle, Emitter}; use crate::events::{ - ChatStatusPayload, EVENT_CHAT_AGENT_REPLY, EVENT_CHAT_APPROVAL_REQUESTED, EVENT_CHAT_REPLY, - EVENT_CHAT_STATUS_CHANGED, EVENT_CHAT_TOOL_FINISHED, EVENT_CHAT_TOOL_REQUESTED, - EVENT_CHAT_TOOL_STARTED, EVENT_CHAT_WARNING, + ChatStatusPayload, TextDeltaPayload, EVENT_CHAT_AGENT_REPLY, EVENT_CHAT_APPROVAL_REQUESTED, + EVENT_CHAT_REPLY, EVENT_CHAT_STATUS_CHANGED, EVENT_CHAT_TEXT_DELTA, EVENT_CHAT_TOOL_FINISHED, + EVENT_CHAT_TOOL_REQUESTED, EVENT_CHAT_TOOL_STARTED, EVENT_CHAT_WARNING, }; +use crate::scope::TurnScope; /// An [`EventSink`] that emits to the webview. /// @@ -21,11 +22,21 @@ use crate::events::{ /// listening. pub struct TauriSink { app: AppHandle, + scope: TurnScope, + emit_approvals: bool, } impl TauriSink { pub fn new(app: AppHandle) -> Self { - Self { app } + Self::scoped(app, TurnScope::default(), true) + } + + pub(crate) fn scoped(app: AppHandle, scope: TurnScope, emit_approvals: bool) -> Self { + Self { + app, + scope, + emit_approvals, + } } fn emit(&self, event: &str, payload: impl Serialize + Clone) { @@ -55,7 +66,23 @@ impl EventSink for TauriSink { self.emit(EVENT_CHAT_TOOL_REQUESTED, call); } + fn text_delta(&self, delta: &str) { + let (session, id) = self.scope.get(); + self.emit( + EVENT_CHAT_TEXT_DELTA, + TextDeltaPayload { + session, + id, + delta: delta.to_owned(), + }, + ); + } + fn approval_requested(&self, request: &ApprovalRequest) { + // `TauriApprovals` emits its own, richer event for the same name. + if !self.emit_approvals { + return; + } self.emit(EVENT_CHAT_APPROVAL_REQUESTED, request); } diff --git a/crates/ed-agent/Cargo.toml b/crates/ed-agent/Cargo.toml index aaeb0aa..e7cf2d5 100644 --- a/crates/ed-agent/Cargo.toml +++ b/crates/ed-agent/Cargo.toml @@ -19,6 +19,7 @@ serde_json.workspace = true log.workspace = true thiserror.workspace = true tokio.workspace = true +futures-core = "0.3" [dev-dependencies] tokio = { version = "1", features = ["macros", "rt", "sync"] } diff --git a/crates/ed-agent/LICENSE-APACHE b/crates/ed-agent/LICENSE-APACHE index 58885ec..bdc140a 100644 --- a/crates/ed-agent/LICENSE-APACHE +++ b/crates/ed-agent/LICENSE-APACHE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Splitfire AB (Onde Inference) + Copyright 2026 Onde Inference Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/crates/ed-agent/LICENSE-MIT b/crates/ed-agent/LICENSE-MIT index 1b62ae8..4b6964c 100644 --- a/crates/ed-agent/LICENSE-MIT +++ b/crates/ed-agent/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Splitfire AB (Onde Inference) +Copyright (c) 2026 Onde Inference Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/crates/ed-agent/README.md b/crates/ed-agent/README.md index f031631..28aa75c 100644 --- a/crates/ed-agent/README.md +++ b/crates/ed-agent/README.md @@ -66,5 +66,5 @@ support list silently disables chat on a platform that would have worked. ## Copyright -© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)). +© 2026 [Onde Inference](https://ondeinference.com). Licensed under MIT or Apache-2.0. diff --git a/crates/ed-agent/src/ed.rs b/crates/ed-agent/src/ed.rs index 6d4e571..1860237 100644 --- a/crates/ed-agent/src/ed.rs +++ b/crates/ed-agent/src/ed.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::future::Future; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use log::{error, info}; use onde::inference::types::{format_duration, ChatMessage}; @@ -14,9 +14,10 @@ use onde::inference::{ }; use crate::{ - tool::truncate_output, AgentConfig, AgentError, AgentReply, AgentToolDefinition, - ApprovalDecision, ApprovalHandler, ApprovalRequest, ChatReply, DenyApprovals, EventSink, - NoopSink, RejectingExecutor, ToolCall, ToolExecutionResult, ToolExecutor, ToolRisk, + stream::chunk_error, tool::truncate_output, AgentConfig, AgentError, AgentReply, + AgentToolDefinition, ApprovalDecision, ApprovalHandler, ApprovalRequest, ChatReply, + DenyApprovals, EventSink, NoopSink, RejectingExecutor, TextStream, ToolCall, + ToolExecutionResult, ToolExecutor, ToolRisk, }; /// An embeddable chat agent over a single [`ChatEngine`]. @@ -226,6 +227,60 @@ impl Ed { reply } + /// Stream the reply to one message, for plain chat without tools. + /// + /// Returns once generation has started; the stream yields text deltas as + /// they are produced. Unlike [`run`](Ed::run) this takes no turn lock and + /// reports nothing to the sink, so don't overlap it with another turn. + /// Drop the stream to cancel; a [`cancel`](Ed::cancel) call is not needed. + pub async fn stream(&self, message: impl Into) -> Result { + Ok(TextStream::new(self.engine.stream_message(message).await?)) + } + + /// One-shot generation over explicit messages. + /// + /// Leaves the conversation history untouched; the engine's system prompt + /// is still applied. `sampling` overrides the engine's for this call only. + pub async fn generate( + &self, + messages: Vec, + sampling: Option, + ) -> Result { + Ok(self.engine.generate(messages, sampling).await?.text) + } + + /// Set the system prompt used for every later turn. + /// + /// Like the other engine setters below, and `restore_history`, this does + /// nothing until a model is loaded: `onde` keeps these on the loaded model. + pub async fn set_system_prompt(&self, prompt: impl Into) { + self.engine.set_system_prompt(prompt).await; + } + + pub async fn clear_system_prompt(&self) { + self.engine.clear_system_prompt().await; + } + + /// Change the default sampling parameters. + pub async fn set_sampling(&self, sampling: SamplingConfig) { + self.engine.set_sampling(sampling).await; + } + + /// Replace the conversation with `messages`. + /// + /// For switching to a saved session or restoring one at launch. Clears + /// the current history first, and with it every `AllowForSession` grant, + /// exactly as [`clear_history`](Ed::clear_history) does: a restored + /// conversation starts without consent from the previous one. + pub async fn restore_history(&self, messages: Vec) { + self.clear_history().await; + let count = messages.len(); + for message in messages { + self.engine.push_history(message).await; + } + info!("ed: restored {count} messages"); + } + /// Register a host capability that the model may request. pub async fn register_tool(&self, tool: AgentToolDefinition) -> Result<(), AgentError> { tool.validate()?; @@ -345,20 +400,39 @@ impl Ed { // `ChatReply` to the sink, and a host listening for both events // would render this turn's answer twice. An agent turn reports // `agent_replied` and nothing else. - let result = - self.engine - .send_message(message) - .await - .map_err(|error| AgentError::Inference { - reason: error.to_string(), - })?; - if result.text.trim().is_empty() { + let started = Instant::now(); + let mut rx = self.engine.stream_message(message).await.map_err(|error| { + AgentError::Inference { + reason: error.to_string(), + } + })?; + let mut text = String::new(); + while let Some(chunk) = rx.recv().await { + if self.cancelled.load(Ordering::Acquire) { + // Dropping the receiver stops generation. + return Err(AgentError::Cancelled); + } + if chunk.done { + if let Some(error) = chunk_error(&chunk) { + return Err(AgentError::Inference { + reason: error.to_string(), + }); + } + break; + } + if !chunk.delta.is_empty() { + self.sink.text_delta(&chunk.delta); + text.push_str(&chunk.delta); + } + } + if text.trim().is_empty() { return Err(AgentError::EmptyReply); } + let elapsed = started.elapsed(); let agent_reply = AgentReply { - text: result.text, - duration_seconds: result.duration_secs, - duration: result.duration_display, + text, + duration_seconds: elapsed.as_secs_f64(), + duration: format_duration(elapsed), tool_rounds: 0, }; self.sink.agent_replied(&agent_reply); @@ -482,6 +556,10 @@ impl Ed { AgentError::EmptyReply }); } + // Tool rounds are not streamed: `onde` only parses tool calls out of a + // complete response, so the answer arrives whole. Report it as one + // delta so a host rendering deltas sees every turn the same way. + self.sink.text_delta(&result.text); let reply = AgentReply { text: result.text, duration_seconds, @@ -833,4 +911,41 @@ mod tests { assert!(result.content.contains("JSON object")); assert_eq!(executor.0.load(Ordering::Relaxed), 0); } + + #[tokio::test] + async fn restoring_history_revokes_session_grants() { + let ed = Ed::with_agent( + NoopSink, + Arc::new(CountingExecutor(AtomicUsize::new(0))), + Arc::new(FixedApproval(ApprovalDecision::AllowForSession)), + AgentConfig::default(), + ); + ed.register_tool(AgentToolDefinition::mutating( + "save_note", + "Save a note", + r#"{"type":"object"}"#, + )) + .await + .unwrap(); + let tools = ed.tools.read().await.clone(); + ed.execute_tool_call(&call("save_note"), &tools).await; + assert!(ed.session_grants.lock().await.contains("save_note")); + + ed.restore_history(Vec::new()).await; + + assert!(ed.session_grants.lock().await.is_empty()); + } + + #[tokio::test] + async fn generation_and_streaming_need_a_loaded_model() { + let ed = Ed::new(); + assert!(matches!( + ed.generate(vec![ChatMessage::user("hi")], None).await, + Err(InferenceError::NoModelLoaded) + )); + assert!(matches!( + ed.stream("hi").await, + Err(InferenceError::NoModelLoaded) + )); + } } diff --git a/crates/ed-agent/src/lib.rs b/crates/ed-agent/src/lib.rs index 5667e22..0a78ec8 100644 --- a/crates/ed-agent/src/lib.rs +++ b/crates/ed-agent/src/lib.rs @@ -15,7 +15,8 @@ //! a frontend wants one shape covering both outcomes. That's [`ChatReply`]. //! //! Ed deliberately does *not* own sessions, persistence, model catalogues, or -//! downloads. Those differ per application and belong to the host. +//! downloads. Those differ per application and belong to the host. For +//! persistence it defines the [`SessionStore`] seam the host implements. //! //! # Platforms //! @@ -49,12 +50,16 @@ mod ed; mod reply; +mod session; mod sink; +mod stream; mod tool; pub use ed::Ed; pub use reply::ChatReply; +pub use session::{SessionError, SessionMeta, SessionStore}; pub use sink::{EventSink, NoopSink, StatusSink}; +pub use stream::TextStream; pub use tool::{ AgentConfig, AgentError, AgentReply, AgentToolDefinition, ApprovalDecision, ApprovalHandler, ApprovalRequest, DenyApprovals, RejectingExecutor, ToolCall, ToolExecutionResult, ToolExecutor, diff --git a/crates/ed-agent/src/session.rs b/crates/ed-agent/src/session.rs new file mode 100644 index 0000000..a6c1440 --- /dev/null +++ b/crates/ed-agent/src/session.rs @@ -0,0 +1,178 @@ +//! The seam between Ed and a host's chat storage. +//! +//! Ed owns no storage. A host that keeps several conversations implements +//! [`SessionStore`] over whatever it already has (files, SQLite, a keychain) +//! and hands the messages to [`Ed::restore_history`](crate::Ed::restore_history) +//! when the user switches conversation. + +use async_trait::async_trait; +use onde::inference::types::ChatMessage; +use serde::{Deserialize, Serialize}; + +/// What a session list needs to render a row, without loading the messages. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionMeta { + pub id: String, + pub title: String, + /// Unix seconds. + pub created_at: i64, + /// Unix seconds. + pub updated_at: i64, + pub message_count: usize, +} + +/// A storage failure. The store decides the wording; Ed only carries it. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{0}")] +pub struct SessionError(pub String); + +/// Persistent conversations, keyed by an id the host chooses. +/// +/// Contract: +/// - `load` of an unknown id is `Ok(None)`, not an error. +/// - `save` creates the session if it doesn't exist and replaces its +/// messages if it does. +/// - `delete` of an unknown id succeeds. +/// - `list` is ordered most recently updated first. +#[async_trait] +pub trait SessionStore: Send + Sync + 'static { + async fn list(&self) -> Result, SessionError>; + async fn load(&self, id: &str) -> Result>, SessionError>; + async fn save(&self, id: &str, messages: &[ChatMessage]) -> Result<(), SessionError>; + async fn delete(&self, id: &str) -> Result<(), SessionError>; +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Mutex; + + use super::*; + + struct Stored { + messages: Vec, + created: i64, + updated: i64, + } + + /// The reference implementation of the contract above. + #[derive(Default)] + struct MemoryStore { + sessions: Mutex>, + clock: Mutex, + } + + impl MemoryStore { + fn tick(&self) -> i64 { + let mut clock = self.clock.lock().unwrap(); + *clock += 1; + *clock + } + } + + #[async_trait] + impl SessionStore for MemoryStore { + async fn list(&self) -> Result, SessionError> { + let mut metas: Vec<_> = self + .sessions + .lock() + .unwrap() + .iter() + .map(|(id, stored)| SessionMeta { + id: id.clone(), + title: stored + .messages + .first() + .map(|m| m.content.clone()) + .unwrap_or_default(), + created_at: stored.created, + updated_at: stored.updated, + message_count: stored.messages.len(), + }) + .collect(); + metas.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(metas) + } + + async fn load(&self, id: &str) -> Result>, SessionError> { + Ok(self + .sessions + .lock() + .unwrap() + .get(id) + .map(|s| s.messages.clone())) + } + + async fn save(&self, id: &str, messages: &[ChatMessage]) -> Result<(), SessionError> { + let now = self.tick(); + let mut sessions = self.sessions.lock().unwrap(); + let created = sessions.get(id).map_or(now, |s| s.created); + sessions.insert( + id.to_owned(), + Stored { + messages: messages.to_vec(), + created, + updated: now, + }, + ); + Ok(()) + } + + async fn delete(&self, id: &str) -> Result<(), SessionError> { + self.sessions.lock().unwrap().remove(id); + Ok(()) + } + } + + #[tokio::test] + async fn loading_an_unknown_session_is_none() { + let store = MemoryStore::default(); + assert!(store.load("nope").await.unwrap().is_none()); + } + + #[tokio::test] + async fn save_creates_then_replaces() { + let store = MemoryStore::default(); + store.save("a", &[ChatMessage::user("hi")]).await.unwrap(); + store + .save( + "a", + &[ChatMessage::user("hi"), ChatMessage::assistant("yo")], + ) + .await + .unwrap(); + + let loaded = store.load("a").await.unwrap().unwrap(); + assert_eq!(loaded.len(), 2); + let listed = store.list().await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].message_count, 2); + assert!(listed[0].updated_at > listed[0].created_at); + } + + #[tokio::test] + async fn list_is_most_recently_updated_first() { + let store = MemoryStore::default(); + store.save("old", &[ChatMessage::user("1")]).await.unwrap(); + store.save("new", &[ChatMessage::user("2")]).await.unwrap(); + store.save("old", &[ChatMessage::user("3")]).await.unwrap(); + + let ids: Vec<_> = store + .list() + .await + .unwrap() + .into_iter() + .map(|m| m.id) + .collect(); + assert_eq!(ids, ["old", "new"]); + } + + #[tokio::test] + async fn delete_is_idempotent() { + let store = MemoryStore::default(); + store.save("a", &[]).await.unwrap(); + store.delete("a").await.unwrap(); + store.delete("a").await.unwrap(); + assert!(store.list().await.unwrap().is_empty()); + } +} diff --git a/crates/ed-agent/src/sink.rs b/crates/ed-agent/src/sink.rs index 316c99c..4069bdd 100644 --- a/crates/ed-agent/src/sink.rs +++ b/crates/ed-agent/src/sink.rs @@ -38,6 +38,14 @@ pub trait EventSink: Send + Sync + 'static { /// pick the result up independently. fn replied(&self, _reply: &ChatReply) {} + /// A piece of the assistant's reply, as it is generated. + /// + /// Called zero or more times during [`Ed::run`](crate::Ed::run) before + /// the final reply is reported. Concatenating the deltas gives the reply + /// text. The default does nothing, so sinks written before streaming + /// existed keep compiling and keep seeing only the finished reply. + fn text_delta(&self, _delta: &str) {} + fn tool_requested(&self, _call: &ToolCall) {} fn approval_requested(&self, _request: &ApprovalRequest) {} fn tool_started(&self, _call: &ToolCall) {} @@ -57,3 +65,18 @@ pub use EventSink as StatusSink; pub struct NoopSink; impl EventSink for NoopSink {} + +#[cfg(test)] +mod tests { + use super::*; + + struct Bare; + impl EventSink for Bare {} + + #[test] + fn text_delta_defaults_to_a_no_op() { + // A sink that predates streaming must still compile and not panic. + Bare.text_delta("hello"); + NoopSink.text_delta("hello"); + } +} diff --git a/crates/ed-agent/src/stream.rs b/crates/ed-agent/src/stream.rs new file mode 100644 index 0000000..e507e63 --- /dev/null +++ b/crates/ed-agent/src/stream.rs @@ -0,0 +1,117 @@ +//! Token streaming for plain chat. + +use std::pin::Pin; +use std::task::{Context, Poll}; + +use futures_core::Stream; +use onde::inference::types::StreamChunk; +use onde::inference::InferenceError; +use tokio::sync::mpsc::Receiver; + +/// The reply to one message, as a stream of text deltas. +/// +/// Yields `Ok(delta)` for each piece and ends after the last one. A failure +/// mid-stream is a final `Err`. Dropping the stream stops generation: `onde` +/// notices the closed channel and keeps only what was delivered. +/// +/// The finished turn is added to the engine's history when the stream ends, +/// as with [`Ed::send`](crate::Ed::send). +pub struct TextStream { + rx: Receiver, + finished: bool, +} + +impl TextStream { + pub(crate) fn new(rx: Receiver) -> Self { + Self { + rx, + finished: false, + } + } +} + +impl TextStream { + /// The next delta, `None` when the reply is complete. For callers without a stream + /// combinator library. + pub async fn next(&mut self) -> Option> { + std::future::poll_fn(|cx| Pin::new(&mut *self).poll_next(cx)).await + } +} + +/// `onde` reports a failed stream as a final chunk whose `finish_reason` +/// starts with `error`. +pub(crate) fn chunk_error(chunk: &StreamChunk) -> Option { + chunk + .finish_reason + .as_deref() + .filter(|reason| reason.starts_with("error")) + .map(|reason| InferenceError::Inference { + reason: reason.trim_start_matches("error:").trim().to_owned(), + }) +} + +impl Stream for TextStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + loop { + if self.finished { + return Poll::Ready(None); + } + match self.rx.poll_recv(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(None) => { + self.finished = true; + return Poll::Ready(None); + } + Poll::Ready(Some(chunk)) => { + if chunk.done { + self.finished = true; + return Poll::Ready(chunk_error(&chunk).map(Err)); + } + if chunk.delta.is_empty() { + continue; + } + return Poll::Ready(Some(Ok(chunk.delta))); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chunk(delta: &str, done: bool, reason: Option<&str>) -> StreamChunk { + StreamChunk { + delta: delta.to_owned(), + done, + finish_reason: reason.map(str::to_owned), + } + } + + #[tokio::test] + async fn yields_deltas_then_ends() { + let (tx, rx) = tokio::sync::mpsc::channel(8); + let mut stream = TextStream::new(rx); + tx.send(chunk("he", false, None)).await.unwrap(); + tx.send(chunk("llo", false, None)).await.unwrap(); + tx.send(chunk("", true, Some("stop"))).await.unwrap(); + + assert_eq!(stream.next().await.unwrap().unwrap(), "he"); + assert_eq!(stream.next().await.unwrap().unwrap(), "llo"); + assert!(stream.next().await.is_none()); + } + + #[tokio::test] + async fn a_failed_stream_ends_with_an_error() { + let (tx, rx) = tokio::sync::mpsc::channel(8); + let mut stream = TextStream::new(rx); + tx.send(chunk("", true, Some("error: boom"))).await.unwrap(); + + let err = stream.next().await.unwrap().unwrap_err(); + assert!(err.to_string().contains("boom")); + assert!(stream.next().await.is_none()); + } +} diff --git a/crates/ed-mcp/Cargo.toml b/crates/ed-mcp/Cargo.toml new file mode 100644 index 0000000..c184014 --- /dev/null +++ b/crates/ed-mcp/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "ed-mcp" +description = "MCP for Onde agents: server configuration, tool namespacing, timeouts, progress and cancellation over rmcp, the official Rust SDK." +keywords = ["mcp", "llm", "agent", "onde"] +categories = ["asynchronous"] +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +agent-client-protocol = { workspace = true, optional = true } +ed-agent = { workspace = true, optional = true } +async-trait = { workspace = true, optional = true } +http = "1" +rmcp = { workspace = true, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "reqwest"] } +tokio = { workspace = true, features = ["sync", "time", "process", "macros", "io-std", "io-util", "rt"] } +tokio-util.workspace = true +futures.workspace = true +serde_json.workspace = true +anyhow.workspace = true +tracing.workspace = true + +[features] +# Map ACP `session/new` MCP server lists (`McpToolset::connect`, `ServerSpec::from_acp`). +# Pulls in `agent-client-protocol`; `ed-acp` enables it. +acp = ["dep:agent-client-protocol"] +# `McpExecutor`: an `McpToolset` as Ed tools. Pulls in `ed-agent` (and with it `onde`). +agent = ["dep:ed-agent", "dep:async-trait"] +# A scriptable MCP server for tests: `echo`, `slow_progress`, `wait_for_cancel`, and the +# demucs-shaped `list_models` / `separate_stems`. +test-server = [] + +[dev-dependencies] +rmcp = { workspace = true, features = ["server", "transport-streamable-http-server"] } +axum = "0.8" +tokio = { workspace = true, features = ["net", "rt-multi-thread"] } +# Run the feature-gated code's tests under a plain `cargo test`. +ed-mcp = { path = ".", features = ["acp", "agent", "test-server"] } diff --git a/crates/ed-mcp/LICENSE-APACHE b/crates/ed-mcp/LICENSE-APACHE new file mode 100644 index 0000000..bdc140a --- /dev/null +++ b/crates/ed-mcp/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Onde Inference + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/ed-mcp/LICENSE-MIT b/crates/ed-mcp/LICENSE-MIT new file mode 100644 index 0000000..4b6964c --- /dev/null +++ b/crates/ed-mcp/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Onde Inference + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/ed-mcp/README.md b/crates/ed-mcp/README.md new file mode 100644 index 0000000..8841d3d --- /dev/null +++ b/crates/ed-mcp/README.md @@ -0,0 +1,33 @@ +# ed-mcp + +MCP client glue over [`rmcp`](https://crates.io/crates/rmcp), the official Rust SDK, for +[Ed](https://crates.io/crates/ed-agent) agents. + +`rmcp` is the client. This crate owns what every agent was writing around it: server +configuration (stdio child processes and streamable HTTP with an optional bearer token), +`mcp____` namespacing, per-server timeouts, progress and cancellation, and read-only +gating from `readOnlyHint`. A server that fails to start is logged and left out. + +```rust +use ed_mcp::{ClientInfo, McpToolset, ServerSpec, ToolsetConfig}; + +let spec = ServerSpec::http("mail", "https://api.example.com/v1/mcp").with_bearer(token); +let tools = McpToolset::connect_specs( + ToolsetConfig::new(ClientInfo::new("my-app", "1.0")), + &[spec], + &[], +) +.await; +``` + +## Features + +- `acp`: map `McpServer` lists from an Agent Client Protocol `session/new` request. +- `agent`: `McpExecutor` serves a toolset to Ed as tools, with risk from `readOnlyHint` plus a + host override list, and optional unprefixed names for single-server apps. +- `test-server`: a scriptable MCP server for tests. + +## Copyright + +© 2026 [Onde Inference](https://ondeinference.com). +Licensed under MIT or Apache-2.0. diff --git a/crates/ed-mcp/src/executor.rs b/crates/ed-mcp/src/executor.rs new file mode 100644 index 0000000..6f0c66d --- /dev/null +++ b/crates/ed-mcp/src/executor.rs @@ -0,0 +1,199 @@ +//! An [`McpToolset`] as [Ed](ed_agent) tools. Behind the `agent` feature. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use async_trait::async_trait; +use ed_agent::{AgentToolDefinition, Ed, EventSink, ToolExecutor, ToolRisk}; +use serde_json::Value; +use tokio::sync::{Mutex, RwLock}; +use tokio_util::sync::CancellationToken; + +use crate::{McpTool, McpToolset}; + +/// How MCP tools are presented to Ed. +#[derive(Debug, Clone, Default)] +pub struct ToolPolicy { + read_only: HashSet, + unprefixed: bool, +} + +impl ToolPolicy { + /// Default policy: tool names are `mcp____`, and a tool is read-only only when + /// its server sets `readOnlyHint: true`. + pub fn new() -> Self { + Self::default() + } + + /// Treat these tools as read-only (no approval) whatever their server says. Names are the + /// server's own tool names, or the names the model sees. For servers that do not set + /// `readOnlyHint`. + pub fn read_only(mut self, names: &[&str]) -> Self { + self.read_only.extend(names.iter().map(|n| (*n).to_owned())); + self + } + + /// Show the model the server's own tool names, without the `mcp____` prefix. For apps + /// with one server whose tool names are already in the system prompt. If two servers use the + /// same tool name the first one wins. + pub fn unprefixed(mut self) -> Self { + self.unprefixed = true; + self + } + + fn risk(&self, tool: &McpTool, exposed: &str) -> ToolRisk { + if tool.read_only || self.read_only.contains(exposed) || self.read_only.contains(&tool.tool) + { + ToolRisk::ReadOnly + } else { + ToolRisk::Mutating + } + } +} + +struct Loaded { + toolset: Arc, + /// Name the model sees -> qualified name in the toolset. + names: HashMap, + definitions: Vec, +} + +/// Runs the tools of an [`McpToolset`] for Ed. +/// +/// Cheap to clone. Hand one clone to [`Ed::with_agent`] as the executor, and keep another to +/// connect and disconnect servers as the user signs in and out: +/// +/// ```ignore +/// let mcp = McpExecutor::new(ToolPolicy::new().read_only(&["list_messages"]).unprefixed()); +/// let ed = Ed::with_agent(sink, Arc::new(mcp.clone()), approvals, config); +/// mcp.set_toolset(Some(toolset)); +/// mcp.sync(&ed).await; +/// ``` +#[derive(Clone)] +pub struct McpExecutor { + policy: Arc, + loaded: Arc>>, + registered: Arc>>, +} + +impl McpExecutor { + pub fn new(policy: ToolPolicy) -> Self { + Self { + policy: Arc::new(policy), + loaded: Arc::default(), + registered: Arc::default(), + } + } + + /// Use `toolset`, or none, and shut the previous toolset down. Call + /// [`sync`](McpExecutor::sync) afterwards. A tool call still running on the old toolset + /// finishes first; its connections close when it does. + pub async fn set_toolset(&self, toolset: Option) { + let new = toolset.map(|toolset| { + let mut names = HashMap::new(); + let mut definitions = Vec::new(); + for tool in toolset.tools() { + let exposed = if self.policy.unprefixed { + tool.tool.clone() + } else { + tool.name.clone() + }; + if names.contains_key(&exposed) { + tracing::warn!( + "duplicate tool name `{exposed}` after prefix removal; keeping the first" + ); + continue; + } + let description = if self.policy.unprefixed { + tool.description + .strip_prefix(&format!("[{} MCP server] ", tool.server)) + .unwrap_or(&tool.description) + .to_owned() + } else { + tool.description.clone() + }; + let description = if description.trim().is_empty() { + format!("The `{}` tool.", tool.tool) + } else { + description + }; + let schema = tool.parameters.to_string(); + definitions.push(match self.policy.risk(tool, &exposed) { + ToolRisk::ReadOnly => { + AgentToolDefinition::read_only(exposed.clone(), description, schema) + } + ToolRisk::Mutating => { + AgentToolDefinition::mutating(exposed.clone(), description, schema) + } + }); + names.insert(exposed, tool.name.clone()); + } + Loaded { + toolset: Arc::new(toolset), + names, + definitions, + } + }); + let old = std::mem::replace(&mut *self.loaded.write().await, new); + if let Some(old) = old + && let Some(toolset) = Arc::into_inner(old.toolset) + { + toolset.shutdown().await; + } + } + + /// The tools Ed should offer, for the connected toolset (empty when none). + pub async fn definitions(&self) -> Vec { + self.loaded + .read() + .await + .as_ref() + .map(|l| l.definitions.clone()) + .unwrap_or_default() + } + + /// Make `ed`'s registered tools match the connected toolset: the ones this executor registered + /// before are removed, the current ones added. + pub async fn sync(&self, ed: &Ed) { + let mut registered = self.registered.lock().await; + for name in registered.drain(..) { + ed.unregister_tool(&name).await; + } + for definition in self.definitions().await { + let name = definition.name.clone(); + match ed.register_tool(definition).await { + Ok(()) => registered.push(name), + Err(error) => tracing::warn!("could not register MCP tool `{name}`: {error}"), + } + } + } +} + +#[async_trait] +impl ToolExecutor for McpExecutor { + async fn execute(&self, tool_name: &str, arguments: &str) -> Result { + let args: Value = + serde_json::from_str(arguments).map_err(|e| format!("bad arguments: {e}"))?; + // Clone out of the lock: a call can run for a long time and must not hold up a + // disconnect. + let (toolset, qualified) = { + let guard = self.loaded.read().await; + let loaded = guard.as_ref().ok_or("no MCP server is connected")?; + let qualified = loaded + .names + .get(tool_name) + .ok_or_else(|| format!("unknown tool `{tool_name}`"))? + .clone(); + (loaded.toolset.clone(), qualified) + }; + let outcome = toolset + .call(&qualified, args, &CancellationToken::new(), &|_| {}) + .await + .map_err(|e| e.to_string())?; + if outcome.failed { + Err(outcome.text) + } else { + Ok(outcome.text) + } + } +} diff --git a/crates/ed-mcp/src/lib.rs b/crates/ed-mcp/src/lib.rs new file mode 100644 index 0000000..dc246c2 --- /dev/null +++ b/crates/ed-mcp/src/lib.rs @@ -0,0 +1,835 @@ +//! MCP for Onde agents, over [`rmcp`], the official Rust SDK. +//! +//! This crate is not an MCP client: `rmcp` is. It owns what every Onde agent was writing for +//! itself around one: +//! +//! - **Server configuration.** Stdio servers supplied by an ACP client in +//! `session/new|load|resume`, plus process-wide built-in servers ([`SharedServer`]) that start +//! at most once, on first need. A client-supplied server with the same name as a built-in wins. +//! - **Tool namespacing.** Tools are exposed to the model as `mcp____`, sanitised +//! and capped at [`MAX_TOOL_NAME`] bytes. +//! - **Read-only gating.** A tool is read-only only when its server sets `readOnlyHint: true`. +//! - **Calls.** Progress notifications are handed to a callback, session cancellation is +//! forwarded as `notifications/cancelled`, and every call has a deadline. +//! +//! A server that fails to start or to list its tools is never fatal: it is logged and its tools +//! are absent. +//! +//! Servers are reached over stdio (a child process) or streamable HTTP with an optional bearer +//! token, described by a [`ServerSpec`]. +//! +//! Features: `acp` maps ACP `McpServer` lists, `agent` adds [`McpExecutor`] to expose a toolset +//! as [Ed](ed_agent) tools, and `test-server` adds a scriptable server for tests. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail}; +use futures::StreamExt; +use rmcp::handler::client::progress::ProgressDispatcher; +use rmcp::model::{ + CallToolRequestParams, ClientCapabilities, ClientRequest, ContentBlock, Implementation, + ProgressNotificationParam, Request, ServerResult, Tool, +}; +use rmcp::service::{NotificationContext, PeerRequestOptions, RunningService}; +use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; +use rmcp::transport::{StreamableHttpClientTransport, TokioChildProcess}; +use rmcp::{ClientHandler, RoleClient, ServiceExt}; +use serde_json::{Map, Value, json}; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +pub use rmcp; + +#[cfg(feature = "agent")] +mod executor; +#[cfg(feature = "agent")] +pub use executor::{McpExecutor, ToolPolicy}; + +#[cfg(feature = "test-server")] +pub mod test_server; + +/// Prefix of every MCP tool name the model sees. +pub const PREFIX: &str = "mcp__"; +/// Longest tool name sent to the model; OpenAI-compatible endpoints reject longer ones. +pub const MAX_TOOL_NAME: usize = 64; +/// Time a server has to finish the `initialize` handshake and answer `tools/list`. +pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(20); +/// Upper bound for one tool call. Long, since some tools (stem separation) take many minutes. +pub const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(60 * 60); + +/// Split `mcp____` into its parts. +pub fn split_qualified(name: &str) -> Option<(&str, &str)> { + let rest = name.strip_prefix(PREFIX)?; + let (server, tool) = rest.split_once("__")?; + (!server.is_empty() && !tool.is_empty()).then_some((server, tool)) +} + +/// The name the model sees for `tool` on `server`. +pub fn qualified(server: &str, tool: &str) -> String { + let mut name = format!("{PREFIX}{}__{}", sanitize(server), sanitize(tool)); + name.truncate(MAX_TOOL_NAME); + name +} + +fn sanitize(part: &str) -> String { + let mut out: String = part + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '_' + } + }) + .collect(); + while out.contains("__") { + out = out.replace("__", "_"); + } + out.trim_matches('_').to_string() +} + +/// Who is connecting: sent to every server in `initialize` as `clientInfo`. +#[derive(Debug, Clone)] +pub struct ClientInfo { + pub name: String, + pub version: String, +} + +impl ClientInfo { + pub fn new(name: impl Into, version: impl Into) -> Self { + Self { + name: name.into(), + version: version.into(), + } + } +} + +/// How to reach a server. +#[derive(Clone)] +pub enum Transport { + /// Start `command args…` with extra environment variables and talk over its stdio. + Stdio { + command: PathBuf, + args: Vec, + env: Vec<(String, String)>, + }, + /// Streamable HTTP. `bearer` is the token without the `Bearer ` prefix. + Http { + url: String, + bearer: Option, + headers: Vec<(String, String)>, + }, +} + +impl std::fmt::Debug for Transport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stdio { command, args, .. } => f + .debug_struct("Stdio") + .field("command", command) + .field("args", args) + .finish_non_exhaustive(), + // The token and header values are credentials; never print them. + Self::Http { url, .. } => f + .debug_struct("Http") + .field("url", url) + .finish_non_exhaustive(), + } + } +} + +/// A server to connect to. +#[derive(Debug, Clone)] +pub struct ServerSpec { + pub name: String, + pub transport: Transport, +} + +impl ServerSpec { + /// A stdio server. + pub fn stdio( + name: impl Into, + command: impl Into, + args: Vec, + env: Vec<(String, String)>, + ) -> Self { + Self { + name: name.into(), + transport: Transport::Stdio { + command: command.into(), + args, + env, + }, + } + } + + /// A streamable HTTP server. + pub fn http(name: impl Into, url: impl Into) -> Self { + Self { + name: name.into(), + transport: Transport::Http { + url: url.into(), + bearer: None, + headers: Vec::new(), + }, + } + } + + /// Send `Authorization: Bearer `. No effect on a stdio server. + pub fn with_bearer(mut self, token: impl Into) -> Self { + if let Transport::Http { bearer, .. } = &mut self.transport { + *bearer = Some(token.into()); + } + self + } + + /// Send an extra header on every request. No effect on a stdio server. + pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + if let Transport::Http { headers, .. } = &mut self.transport { + headers.push((name.into(), value.into())); + } + self + } + + /// The spec for an ACP-supplied server. + #[cfg(feature = "acp")] + pub fn from_acp(server: &agent_client_protocol::schema::v1::McpServer) -> Option { + use agent_client_protocol::schema::v1::McpServer; + match server { + McpServer::Stdio(s) => Some(Self::stdio( + s.name.clone(), + s.command.clone(), + s.args.clone(), + s.env + .iter() + .map(|e| (e.name.clone(), e.value.clone())) + .collect(), + )), + McpServer::Http(h) => Some(Self { + name: h.name.clone(), + transport: Transport::Http { + url: h.url.clone(), + bearer: None, + headers: h + .headers + .iter() + .map(|h| (h.name.clone(), h.value.clone())) + .collect(), + }, + }), + _ => None, + } + } +} + +struct Handler { + progress: ProgressDispatcher, + info: ClientInfo, +} + +impl ClientHandler for Handler { + async fn on_progress( + &self, + params: ProgressNotificationParam, + _context: NotificationContext, + ) { + self.progress.handle_notification(params).await; + } + + fn get_info(&self) -> rmcp::model::ClientConfig { + rmcp::model::ClientConfig::new( + ClientCapabilities::default(), + Implementation::new(self.info.name.clone(), self.info.version.clone()), + ) + } +} + +/// One live connection to an MCP server. +pub struct Connection { + name: String, + service: RunningService, + tools: Vec, +} + +impl Connection { + /// Connect to `spec`, run the handshake and list its tools, each within `timeout`. + pub async fn connect(spec: &ServerSpec, info: &ClientInfo, timeout: Duration) -> Result { + let handler = Handler { + progress: ProgressDispatcher::new(), + info: info.clone(), + }; + let handshake = match &spec.transport { + Transport::Stdio { command, args, env } => { + let mut cmd = tokio::process::Command::new(command); + cmd.args(args).envs(env.iter().map(|(k, v)| (k, v))); + let transport = TokioChildProcess::new(cmd) + .with_context(|| format!("starting {}", command.display()))?; + tokio::time::timeout(timeout, handler.serve(transport)).await + } + Transport::Http { + url, + bearer, + headers, + } => { + let mut config = StreamableHttpClientTransportConfig::with_uri(url.as_str()); + if let Some(token) = bearer { + config = config.auth_header(token.as_str()); + } + if !headers.is_empty() { + let mut map = HashMap::new(); + for (name, value) in headers { + map.insert( + http::HeaderName::from_bytes(name.as_bytes()) + .with_context(|| format!("invalid header name `{name}`"))?, + http::HeaderValue::from_str(value) + .with_context(|| format!("invalid value for header `{name}`"))?, + ); + } + config = config.custom_headers(map); + } + let transport = StreamableHttpClientTransport::from_config(config); + tokio::time::timeout(timeout, handler.serve(transport)).await + } + }; + let service = handshake + .map_err(|_| anyhow!("no MCP handshake within {}s", timeout.as_secs()))? + .map_err(|e| anyhow!("MCP handshake failed: {e}"))?; + let tools = tokio::time::timeout(timeout, service.peer().list_all_tools()) + .await + .map_err(|_| anyhow!("tools/list timed out"))? + .map_err(|e| anyhow!("tools/list failed: {e}"))?; + Ok(Self { + name: spec.name.clone(), + service, + tools, + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + /// The tools the server listed when it connected, under their own names. + pub fn tools(&self) -> &[Tool] { + &self.tools + } + + /// Call a tool on this server directly, outside any turn (for example to build a + /// permission prompt). Answers `None` on any failure or after `timeout`. + pub async fn call_raw(&self, tool: &str, args: Value, timeout: Duration) -> Option { + self.call_tool(tool, args, timeout).await.ok() + } + + /// Call `tool` by its name on this server and return the `CallToolResult` as JSON. A + /// result with `isError: true` is `Ok`; a transport or protocol failure, or no answer + /// within `timeout`, is an `Err` that says which. + pub async fn call_tool(&self, tool: &str, args: Value, timeout: Duration) -> Result { + let mut params = CallToolRequestParams::new(tool.to_string()); + match args { + Value::Object(map) => params = params.with_arguments(map), + Value::Null => {} + _ => bail!("tool arguments must be a JSON object"), + } + let result = tokio::time::timeout(timeout, self.service.peer().call_tool(params)) + .await + .map_err(|_| anyhow!("timed out after {}s", timeout.as_secs()))? + .map_err(|e| anyhow!("{e}"))?; + Ok(serde_json::to_value(result)?) + } + + async fn close(mut self) { + let _ = self + .service + .close_with_timeout(Duration::from_secs(2)) + .await; + } +} + +enum SharedState { + NotStarted, + Unavailable, + Running(Arc), +} + +/// A built-in server shared by every session in the process, started at most once, on first +/// need. `resolve` decides at that moment whether it is available: `None` (disabled, binary +/// missing) is logged once and the server stays absent for the life of the process. +/// +/// ```ignore +/// static DEMUCS: SharedServer = SharedServer::new("demucs", demucs_spec); +/// ``` +pub struct SharedServer { + name: &'static str, + resolve: fn() -> Option, + state: Mutex, +} + +impl SharedServer { + pub const fn new(name: &'static str, resolve: fn() -> Option) -> Self { + Self { + name, + resolve, + state: Mutex::const_new(SharedState::NotStarted), + } + } + + pub fn name(&self) -> &'static str { + self.name + } + + async fn get(&self, info: &ClientInfo, timeout: Duration) -> Option> { + let mut state = self.state.lock().await; + match &*state { + SharedState::Running(conn) => return Some(conn.clone()), + SharedState::Unavailable => return None, + SharedState::NotStarted => {} + } + *state = SharedState::Unavailable; + let Some(spec) = (self.resolve)() else { + tracing::info!("built-in MCP server `{}` is not available", self.name); + return None; + }; + match Connection::connect(&spec, info, timeout).await { + Ok(conn) => { + tracing::info!("built-in MCP server `{}` available", self.name); + let conn = Arc::new(conn); + *state = SharedState::Running(conn.clone()); + Some(conn) + } + Err(e) => { + tracing::info!("built-in MCP server `{}` unavailable: {e:#}", self.name); + None + } + } + } + + /// Stop the server if it was started. Call once every session's [`McpToolset`] has been + /// shut down, so this is the last owner. + pub async fn shutdown(&self) { + let state = std::mem::replace(&mut *self.state.lock().await, SharedState::Unavailable); + if let SharedState::Running(conn) = state + && let Some(conn) = Arc::into_inner(conn) + { + conn.close().await; + } + } +} + +/// How to connect a session's servers. +#[derive(Clone)] +pub struct ToolsetConfig { + pub client: ClientInfo, + pub connect_timeout: Duration, + pub call_timeout: Duration, +} + +impl ToolsetConfig { + pub fn new(client: ClientInfo) -> Self { + Self { + client, + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + call_timeout: DEFAULT_CALL_TIMEOUT, + } + } +} + +/// One MCP tool as the model sees it. +#[derive(Debug, Clone)] +pub struct McpTool { + /// `mcp____`. + pub name: String, + pub server: String, + /// The tool's name on its server. + pub tool: String, + /// The server's description, prefixed with `[ MCP server]`. + pub description: String, + /// JSON Schema for the arguments; always has `"type": "object"`. + pub parameters: Value, + pub read_only: bool, +} + +struct Entry { + conn: Arc, + tool: McpTool, +} + +/// What a call produced, flattened for the model. +#[derive(Debug, Clone)] +pub struct CallOutcome { + /// Text content joined by newlines; non-text blocks are described in brackets, and + /// `structuredContent` is used when there is no content at all. + pub text: String, + /// The server reported `isError: true`, or the call failed, was cancelled or timed out. + pub failed: bool, + /// `structuredContent`, when the server sent one. + pub structured: Option, +} + +impl CallOutcome { + fn failed(text: impl Into) -> Self { + Self { + text: text.into(), + failed: true, + structured: None, + } + } +} + +/// The MCP tools available to one session. +pub struct McpToolset { + config: ToolsetConfig, + tools: HashMap, + conns: Vec>, +} + +impl McpToolset { + /// A toolset with no servers. + pub fn empty(config: ToolsetConfig) -> Self { + Self { + config, + tools: HashMap::new(), + conns: Vec::new(), + } + } + + /// Connect the client-supplied ACP servers (stdio and HTTP) and every built-in the client + /// did not supply a server of the same name for. + #[cfg(feature = "acp")] + pub async fn connect( + config: ToolsetConfig, + client_servers: &[agent_client_protocol::schema::v1::McpServer], + builtins: &[&'static SharedServer], + ) -> Self { + let specs: Vec = client_servers + .iter() + .filter_map(|server| { + let spec = ServerSpec::from_acp(server); + if spec.is_none() { + tracing::info!("ignoring MCP server of an unsupported transport"); + } + spec + }) + .collect(); + Self::connect_specs(config, &specs, builtins).await + } + + /// Connect `servers` and every built-in no server of the same name was supplied for. A server + /// that fails is logged and left out. + pub async fn connect_specs( + config: ToolsetConfig, + servers: &[ServerSpec], + builtins: &[&'static SharedServer], + ) -> Self { + let mut set = Self::empty(config); + for spec in servers { + match Connection::connect(spec, &set.config.client, set.config.connect_timeout).await { + Ok(conn) => set.add(Arc::new(conn)), + Err(e) => tracing::warn!("MCP server `{}` unavailable: {e:#}", spec.name), + } + } + for builtin in builtins { + if servers.iter().any(|s| s.name == builtin.name()) { + continue; + } + if let Some(conn) = builtin + .get(&set.config.client, set.config.connect_timeout) + .await + { + set.add(conn); + } + } + set + } + + fn add(&mut self, conn: Arc) { + for tool in &conn.tools { + let name = qualified(&conn.name, &tool.name); + if self.tools.contains_key(&name) { + tracing::warn!("duplicate MCP tool name {name}; keeping the first"); + continue; + } + let mut parameters = Value::Object((*tool.input_schema).clone()); + if parameters.get("type").is_none() { + parameters["type"] = json!("object"); + } + let read_only = tool + .annotations + .as_ref() + .and_then(|a| a.read_only_hint) + .unwrap_or(false); + let entry = McpTool { + name: name.clone(), + server: conn.name.clone(), + tool: tool.name.to_string(), + description: format!( + "[{} MCP server] {}", + conn.name, + tool.description.as_deref().unwrap_or("") + ), + parameters, + read_only, + }; + self.tools.insert( + name, + Entry { + conn: conn.clone(), + tool: entry, + }, + ); + } + self.conns.push(conn); + } + + /// Every tool, sorted by name. + pub fn tools(&self) -> Vec<&McpTool> { + let mut tools: Vec<&McpTool> = self.tools.values().map(|e| &e.tool).collect(); + tools.sort_by(|a, b| a.name.cmp(&b.name)); + tools + } + + /// The tool the model calls `name`, if any. + pub fn tool(&self, name: &str) -> Option<&McpTool> { + self.tools.get(name).map(|e| &e.tool) + } + + /// Whether `tool` on `server` is connected. + pub fn has_tool(&self, server: &str, tool: &str) -> bool { + self.tools.contains_key(&qualified(server, tool)) + } + + /// The connection serving the model-facing tool `name`. + pub fn connection(&self, name: &str) -> Option<&Connection> { + self.tools.get(name).map(|e| e.conn.as_ref()) + } + + /// Call `name` with `args`. `on_progress` gets one line per MCP progress notification + /// (`"Separating (25%)"`). Cancelling `cancel` sends `notifications/cancelled` and returns + /// a failed outcome. Only an unknown tool or malformed arguments are an `Err`. + pub async fn call( + &self, + name: &str, + args: Value, + cancel: &CancellationToken, + on_progress: &(dyn Fn(String) + Send + Sync), + ) -> Result { + let entry = self + .tools + .get(name) + .ok_or_else(|| anyhow!("unknown tool `{name}`"))?; + let arguments: Map = match args { + Value::Object(o) => o, + Value::Null => Map::new(), + _ => bail!("tool arguments must be a JSON object"), + }; + let params = CallToolRequestParams::new(entry.tool.tool.clone()).with_arguments(arguments); + let mut handle = match entry + .conn + .service + .peer() + .send_cancellable_request( + ClientRequest::CallToolRequest(Request::new(params)), + PeerRequestOptions::no_options(), + ) + .await + { + Ok(handle) => handle, + Err(e) => { + return Ok(CallOutcome::failed(format!( + "MCP server `{}` is not responding: {e}", + entry.conn.name + ))); + } + }; + let mut progress = entry + .conn + .service + .service() + .progress + .subscribe(handle.progress_token.clone()) + .await; + let deadline = tokio::time::sleep(self.config.call_timeout); + tokio::pin!(deadline); + + enum End { + Done(Box>), + Cancelled, + TimedOut, + } + let end = loop { + tokio::select! { + r = &mut handle.rx => { + break End::Done(Box::new(r.unwrap_or(Err(rmcp::ServiceError::TransportClosed)))); + } + Some(p) = progress.next() => on_progress(progress_line(&p)), + () = cancel.cancelled() => break End::Cancelled, + () = &mut deadline => break End::TimedOut, + } + }; + Ok(match end { + End::Done(done) => match *done { + Ok(ServerResult::CallToolResult(r)) => outcome_from(r), + Ok(_) => CallOutcome::failed("The MCP server sent an unexpected response."), + Err(e) => CallOutcome::failed(format!("MCP call failed: {e}")), + }, + End::Cancelled => { + let _ = handle.cancel(Some("cancelled by user".into())).await; + CallOutcome::failed("Cancelled by user.") + } + End::TimedOut => { + let _ = handle.cancel(Some("timed out".into())).await; + CallOutcome::failed(format!( + "Timed out after {} minutes.", + self.config.call_timeout.as_secs() / 60 + )) + } + }) + } + + /// Close this session's own servers. Built-ins stay up for other sessions. + pub async fn shutdown(self) { + drop(self.tools); + for conn in self.conns { + if let Some(conn) = Arc::into_inner(conn) { + conn.close().await; + } + } + } +} + +/// One line for an MCP progress notification: its message, and a percentage when it has a +/// total. +pub fn progress_line(p: &ProgressNotificationParam) -> String { + let pct = p + .total + .filter(|t| *t > 0.0) + .map(|t| format!(" ({:.0}%)", 100.0 * p.progress / t)); + format!( + "{}{}", + p.message.as_deref().unwrap_or("Working…"), + pct.unwrap_or_default() + ) +} + +fn outcome_from(r: rmcp::model::CallToolResult) -> CallOutcome { + let mut parts = Vec::new(); + for block in &r.content { + match block { + ContentBlock::Text(t) => parts.push(t.text.clone()), + ContentBlock::Image(i) => parts.push(format!("[image: {}]", i.mime_type)), + ContentBlock::Audio(a) => parts.push(format!("[audio: {}]", a.mime_type)), + ContentBlock::ResourceLink(l) => parts.push(format!("[resource: {}]", l.uri)), + ContentBlock::Resource(_) => parts.push("[embedded resource]".into()), + _ => parts.push("[unsupported content]".into()), + } + } + let mut text = parts.join("\n"); + if text.is_empty() { + text = r + .structured_content + .as_ref() + .map(|v| v.to_string()) + .unwrap_or_else(|| "(no output)".into()); + } + CallOutcome { + text, + failed: r.is_error == Some(true), + structured: r.structured_content, + } +} + +/// `true` when an `*_OFF`-style switch is set to `off`, `0`, `false` or `no`. +pub fn switched_off(var: &str) -> bool { + std::env::var(var).is_ok_and(|v| { + matches!( + v.to_ascii_lowercase().as_str(), + "off" | "0" | "false" | "no" + ) + }) +} + +/// `override_var` when set and non-empty, else `binary` found on `PATH`. +pub fn find_binary(override_var: &str, binary: &str) -> Option { + if let Some(bin) = std::env::var_os(override_var).filter(|v| !v.is_empty()) { + return Some(bin.into()); + } + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join(binary)) + .find(|candidate| is_file(candidate)) +} + +fn is_file(path: &Path) -> bool { + path.is_file() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn qualified_names_round_trip() { + let q = qualified("My Server!", "do.thing"); + assert_eq!(q, "mcp__My_Server__do_thing"); + assert_eq!(split_qualified(&q), Some(("My_Server", "do_thing"))); + assert_eq!(split_qualified("read_file"), None); + assert_eq!(split_qualified("mcp__x"), None); + assert_eq!(split_qualified("mcp____t"), None); + assert!(qualified("s", &"t".repeat(100)).len() <= MAX_TOOL_NAME); + } + + #[test] + fn progress_text() { + let p = ProgressNotificationParam::new( + rmcp::model::ProgressToken(rmcp::model::NumberOrString::Number(1)), + 25.0, + ) + .with_total(100.0) + .with_message("Separating"); + assert_eq!(progress_line(&p), "Separating (25%)"); + let bare = ProgressNotificationParam::new( + rmcp::model::ProgressToken(rmcp::model::NumberOrString::Number(2)), + 3.0, + ); + assert_eq!(progress_line(&bare), "Working…"); + } + + #[test] + fn structured_content_stands_in_for_empty_content() { + let r: rmcp::model::CallToolResult = serde_json::from_value(json!({ + "content": [], + "structuredContent": {"stems": []}, + "isError": false + })) + .unwrap(); + let out = outcome_from(r); + assert_eq!(out.text, r#"{"stems":[]}"#); + assert!(!out.failed); + assert_eq!(out.structured, Some(json!({"stems": []}))); + } + + #[test] + fn errors_are_reported_as_failed() { + let r: rmcp::model::CallToolResult = serde_json::from_value(json!({ + "content": [{"type": "text", "text": "busy"}], + "isError": true + })) + .unwrap(); + let out = outcome_from(r); + assert_eq!(out.text, "busy"); + assert!(out.failed); + } + + #[test] + fn off_switches() { + // SAFETY: only this test touches this variable. + unsafe { std::env::set_var("ED_MCP_TEST_SWITCH", "OFF") }; + assert!(switched_off("ED_MCP_TEST_SWITCH")); + unsafe { std::env::set_var("ED_MCP_TEST_SWITCH", "on") }; + assert!(!switched_off("ED_MCP_TEST_SWITCH")); + unsafe { std::env::remove_var("ED_MCP_TEST_SWITCH") }; + assert!(!switched_off("ED_MCP_TEST_SWITCH")); + } +} diff --git a/crates/ed-mcp/src/test_server.rs b/crates/ed-mcp/src/test_server.rs new file mode 100644 index 0000000..6401e54 --- /dev/null +++ b/crates/ed-mcp/src/test_server.rs @@ -0,0 +1,185 @@ +//! A test MCP server over stdio (newline-delimited JSON-RPC). Stands in for third-party servers +//! and for built-ins such as `demucs --mcp`: it ignores its arguments. A product exposes it as a +//! binary whose `main` calls [`run`] and points `ed_acp_testkit::Target::mcp_stub` at it. Behind the +//! `test-server` feature. +//! +//! Set `STUB_PID_FILE` to have it write its process id, so a test can check it was stopped. +//! +//! Tools: `echo` (read-only), `slow_progress` (three progress notifications), +//! `wait_for_cancel` (answers only after `notifications/cancelled`), and the demucs-shaped +//! `list_models` / `separate_stems`. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +fn tools() -> Value { + json!([ + { + "name": "echo", + "description": "Echo the text back.", + "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}, + "annotations": {"readOnlyHint": true} + }, + { + "name": "slow_progress", + "description": "Report progress three times, then finish.", + "inputSchema": {"type": "object", "properties": {}} + }, + { + "name": "wait_for_cancel", + "description": "Blocks until the call is cancelled.", + "inputSchema": {"type": "object", "properties": {}} + }, + { + "name": "list_models", + "description": "List stem separation models.", + "inputSchema": {"type": "object", "properties": {}}, + "annotations": {"readOnlyHint": true} + }, + { + "name": "separate_stems", + "description": "Pretend to separate stems.", + "inputSchema": {"type": "object", "properties": {"input": {"type": "string"}, "model": {"type": "string"}}, "required": ["input"]} + } + ]) +} + +/// Serve on stdin/stdout until stdin closes. +pub async fn run() { + // Lets tests find out whether this process is still alive after the agent closes it. + if let Some(path) = std::env::var_os("STUB_PID_FILE") { + let _ = std::fs::write(path, std::process::id().to_string()); + } + let out = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout())); + let cancelled: Arc>> = Arc::default(); + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + + let send = { + let out = out.clone(); + move |msg: Value| { + let out = out.clone(); + async move { + let mut o = out.lock().await; + let _ = o.write_all(format!("{msg}\n").as_bytes()).await; + let _ = o.flush().await; + } + } + }; + + while let Ok(Some(line)) = lines.next_line().await { + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + let method = msg + .get("method") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let id = msg.get("id").cloned(); + let params = msg.get("params").cloned().unwrap_or(Value::Null); + let Some(id) = id else { + if method == "notifications/cancelled" + && let Some(rid) = params.get("requestId") + { + cancelled.lock().unwrap().insert(rid.to_string()); + } + continue; + }; + match method.as_str() { + "initialize" => { + let version = params + .get("protocolVersion") + .cloned() + .unwrap_or(json!("2025-06-18")); + send(json!({"jsonrpc": "2.0", "id": id, "result": { + "protocolVersion": version, + "capabilities": {"tools": {}}, + "serverInfo": {"name": "stub", "version": "0.0.0"} + }})) + .await; + } + "ping" => send(json!({"jsonrpc": "2.0", "id": id, "result": {}})).await, + "tools/list" => { + send(json!({"jsonrpc": "2.0", "id": id, "result": {"tools": tools()}})).await + } + "tools/call" => { + let send = send.clone(); + let cancelled = cancelled.clone(); + tokio::spawn(async move { + let name = params + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let args = params.get("arguments").cloned().unwrap_or(json!({})); + let token = params.pointer("/_meta/progressToken").cloned(); + let progress = |n: u32, msg: &str| { + token.as_ref().map(|t| { + json!({"jsonrpc": "2.0", "method": "notifications/progress", + "params": {"progressToken": t, "progress": n, "total": 100, "message": msg}}) + }) + }; + let result = match name.as_str() { + "echo" => { + let text = args.get("text").and_then(Value::as_str).unwrap_or(""); + json!({"content": [{"type": "text", "text": format!("echo: {text}")}], "isError": false}) + } + "slow_progress" | "separate_stems" => { + for (n, m) in [(10, "starting"), (50, "halfway"), (90, "almost")] { + if let Some(p) = progress(n, m) { + send(p).await; + } + tokio::time::sleep(Duration::from_millis(40)).await; + } + if name == "slow_progress" { + json!({"content": [{"type": "text", "text": "done"}], "isError": false}) + } else { + let input = args + .get("input") + .and_then(Value::as_str) + .unwrap_or("/tmp/x.wav"); + let dir = format!("{}_stems", input.trim_end_matches(".wav")); + let stems: Vec = ["drums", "bass", "vocals", "other"] + .iter() + .map(|s| json!({"id": s, "path": format!("{dir}/{s}.wav")})) + .collect(); + json!({ + "content": [{"type": "text", "text": format!("Separated into {} stems", stems.len())}], + "structuredContent": {"stems": stems}, + "isError": false + }) + } + } + "list_models" => json!({ + "content": [{"type": "text", "text": "htdemucs 80 MB"}], + "structuredContent": {"models": [ + {"id": "htdemucs", "size_mb": 80, "cached": false}, + {"id": "htdemucs_6s", "size_mb": 55, "cached": true} + ]}, + "isError": false + }), + "wait_for_cancel" => { + let key = id.to_string(); + while !cancelled.lock().unwrap().contains(&key) { + tokio::time::sleep(Duration::from_millis(20)).await; + } + json!({"content": [{"type": "text", "text": "cancelled"}], "isError": true}) + } + other => { + send(json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32602, "message": format!("unknown tool {other}")}})).await; + return; + } + }; + send(json!({"jsonrpc": "2.0", "id": id, "result": result})).await; + }); + } + other => { + send(json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": format!("method not found: {other}")}})).await; + } + } + } +} diff --git a/crates/ed-mcp/tests/http.rs b/crates/ed-mcp/tests/http.rs new file mode 100644 index 0000000..0528b74 --- /dev/null +++ b/crates/ed-mcp/tests/http.rs @@ -0,0 +1,243 @@ +//! Streamable HTTP against an in-process rmcp server, and the Ed executor on top of it. + +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::Request; +use axum::http::StatusCode; +use axum::middleware::{self, Next}; +use axum::response::Response; +use ed_agent::{ToolExecutor, ToolRisk}; +use ed_mcp::{ + ClientInfo, Connection, McpExecutor, McpToolset, ServerSpec, ToolPolicy, ToolsetConfig, +}; +use rmcp::RoleServer; +use rmcp::ServerHandler; +use rmcp::model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult, + ServerCapabilities, ServerConfig, Tool, ToolAnnotations, +}; +use rmcp::service::RequestContext; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; +use serde_json::json; +use tokio_util::sync::CancellationToken; + +const TOKEN: &str = "s3cret"; + +#[derive(Clone)] +struct Mailbox; + +impl ServerHandler for Mailbox { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let schema = Arc::new(json!({"type": "object"}).as_object().unwrap().clone()); + Ok(ListToolsResult { + tools: vec![ + // Declares itself read-only. + Tool::new("whoami", "Who is signed in.", schema.clone()) + .annotate(ToolAnnotations::new().read_only(true)), + // Says nothing, like a server that predates annotations. + Tool::new("list_messages", "Recent messages.", schema.clone()), + Tool::new("send_message", "Send mail.", schema), + ], + ..Default::default() + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + Ok( + CallToolResult::success(vec![ContentBlock::text(format!("ran {}", request.name))]) + .into(), + ) + } +} + +async fn require_bearer(request: Request, next: Next) -> Result { + let ok = request + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + == Some(&format!("Bearer {TOKEN}")); + if ok { + Ok(next.run(request).await) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +/// Serve [`Mailbox`] behind bearer auth; returns its URL. +async fn serve() -> String { + let config = StreamableHttpServerConfig::default() + .with_legacy_session_mode(true) + .with_sse_keep_alive(None) + .with_cancellation_token(CancellationToken::new()); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(Mailbox), Default::default(), config); + let router = axum::Router::new() + .nest_service("/mcp", service) + .layer(middleware::from_fn(require_bearer)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + format!("http://{addr}/mcp") +} + +fn client() -> ClientInfo { + ClientInfo::new("ed-mcp-test", "0") +} + +#[tokio::test] +async fn connects_lists_and_calls_with_a_bearer_token() { + let url = serve().await; + let spec = ServerSpec::http("mail", url).with_bearer(TOKEN); + + let set = McpToolset::connect_specs(ToolsetConfig::new(client()), &[spec], &[]).await; + + let names: Vec<_> = set.tools().iter().map(|t| t.name.clone()).collect(); + assert_eq!( + names, + [ + "mcp__mail__list_messages", + "mcp__mail__send_message", + "mcp__mail__whoami" + ] + ); + let out = set + .call( + "mcp__mail__whoami", + json!({}), + &CancellationToken::new(), + &|_| {}, + ) + .await + .unwrap(); + assert_eq!(out.text, "ran whoami"); + assert!(!out.failed); + set.shutdown().await; +} + +#[tokio::test] +async fn a_connection_lists_and_calls_tools_by_their_own_names() { + let url = serve().await; + let spec = ServerSpec::http("mail", url).with_bearer(TOKEN); + let conn = Connection::connect(&spec, &client(), Duration::from_secs(5)) + .await + .unwrap(); + + let mut names: Vec<_> = conn.tools().iter().map(|t| t.name.to_string()).collect(); + names.sort(); + assert_eq!(names, ["list_messages", "send_message", "whoami"]); + + let result = conn + .call_tool("whoami", json!({}), Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!(result["content"][0]["text"], "ran whoami"); + let err = conn + .call_tool("whoami", json!([1]), Duration::from_secs(5)) + .await + .unwrap_err(); + assert!(err.to_string().contains("JSON object"), "{err:#}"); +} + +#[tokio::test] +async fn a_wrong_bearer_token_is_rejected() { + let url = serve().await; + let spec = ServerSpec::http("mail", url).with_bearer("nope"); + + let err = Connection::connect(&spec, &client(), Duration::from_secs(5)) + .await + .err() + .expect("the server answers 401"); + assert!(err.to_string().contains("handshake failed"), "{err:#}"); + + // A rejected server is left out, not fatal. + let set = McpToolset::connect_specs(ToolsetConfig::new(client()), &[spec], &[]).await; + assert!(set.tools().is_empty()); +} + +#[tokio::test] +async fn a_missing_bearer_token_is_rejected() { + let url = serve().await; + let spec = ServerSpec::http("mail", url); + assert!( + Connection::connect(&spec, &client(), Duration::from_secs(5)) + .await + .is_err() + ); +} + +async fn executor(policy: ToolPolicy) -> McpExecutor { + let url = serve().await; + let spec = ServerSpec::http("mail", url).with_bearer(TOKEN); + let set = McpToolset::connect_specs(ToolsetConfig::new(client()), &[spec], &[]).await; + let exec = McpExecutor::new(policy); + exec.set_toolset(Some(set)).await; + exec +} + +fn risk(defs: &[ed_agent::AgentToolDefinition], name: &str) -> ToolRisk { + defs.iter().find(|d| d.name == name).unwrap().risk +} + +#[tokio::test] +async fn risk_follows_the_read_only_hint() { + let defs = executor(ToolPolicy::new()).await.definitions().await; + + assert_eq!(risk(&defs, "mcp__mail__whoami"), ToolRisk::ReadOnly); + // No hint means it may change things. + assert_eq!(risk(&defs, "mcp__mail__list_messages"), ToolRisk::Mutating); + assert_eq!(risk(&defs, "mcp__mail__send_message"), ToolRisk::Mutating); +} + +#[tokio::test] +async fn the_host_can_override_risk_and_drop_the_prefix() { + let policy = ToolPolicy::new() + .read_only(&["list_messages", "whoami"]) + .unprefixed(); + let exec = executor(policy).await; + let defs = exec.definitions().await; + + assert_eq!(risk(&defs, "list_messages"), ToolRisk::ReadOnly); + assert_eq!(risk(&defs, "whoami"), ToolRisk::ReadOnly); + assert_eq!(risk(&defs, "send_message"), ToolRisk::Mutating); + assert_eq!( + defs.iter() + .find(|d| d.name == "send_message") + .unwrap() + .description, + "Send mail." + ); + + // The executor answers to the names the model sees. + assert_eq!( + exec.execute("send_message", "{}").await.unwrap(), + "ran send_message" + ); + assert!(exec.execute("mcp__mail__send_message", "{}").await.is_err()); +} + +#[tokio::test] +async fn disconnecting_removes_the_tools() { + let exec = executor(ToolPolicy::new()).await; + assert!(!exec.definitions().await.is_empty()); + + exec.set_toolset(None).await; + + assert!(exec.definitions().await.is_empty()); + assert!(exec.execute("mcp__mail__whoami", "{}").await.is_err()); +} diff --git a/crates/ed-onde-account/Cargo.toml b/crates/ed-onde-account/Cargo.toml new file mode 100644 index 0000000..76f551f --- /dev/null +++ b/crates/ed-onde-account/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ed-onde-account" +description = "Onde Inference account client for Onde agents: sign in, list and activate apps, and mint an Onde Cloud API key through the ondeinference.com API. Holds no Onde secrets." +keywords = ["onde", "agent", "auth", "api-key"] +categories = ["api-bindings", "authentication"] +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +axum = "0.8" +tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread"] } diff --git a/crates/ed-onde-account/LICENSE-APACHE b/crates/ed-onde-account/LICENSE-APACHE new file mode 100644 index 0000000..58885ec --- /dev/null +++ b/crates/ed-onde-account/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Splitfire AB (Onde Inference) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/ed-onde-account/LICENSE-MIT b/crates/ed-onde-account/LICENSE-MIT new file mode 100644 index 0000000..1b62ae8 --- /dev/null +++ b/crates/ed-onde-account/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Splitfire AB (Onde Inference) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/ed-onde-account/README.md b/crates/ed-onde-account/README.md new file mode 100644 index 0000000..b78c05b --- /dev/null +++ b/crates/ed-onde-account/README.md @@ -0,0 +1,35 @@ +# ed-onde-account + +An Onde Inference account client for [Ed](https://crates.io/crates/ed-agent) agents. + +It signs a user in to their Onde account, finds or creates their app, activates it, and returns +the Onde Cloud API key (`app_id:app_secret`) an agent authenticates with. Every call goes through +the ondeinference.com API, which keeps Onde's smbCloud credentials on the server, so nothing +secret ships in the app that embeds this crate. + +```rust +use ed_onde_account::{OndeAccount, SignIn}; + +let account = OndeAccount::new(); // https://ondeinference.com, production +match account.sign_in("me@example.com", "password").await? { + SignIn::Ready(token) => { + // Reuses the app named "My Agent", or creates and activates it. + let key = account.ensure_key(&token, "My Agent").await?; + println!("{}", key.as_str()); // app_id:app_secret + } + SignIn::NotFound => { /* no account yet */ } + SignIn::Incomplete { .. } => { /* e.g. the email isn't confirmed */ } +} +``` + +- `sign_in`, `me`, `apps`, `create_app`, `activate` and `ensure_key`. +- `ensure_key` is idempotent: it never creates a second app with the same name. +- Errors are typed. `Error::Unauthorized` means the token expired and the user must sign in again; + `Error::Forbidden` means the account may not create apps. +- `AccessToken` and `ApiKey` redact themselves in `Debug` output. + +Creating apps needs an Onde account that is allowed to, which every signed-up user is. + +## License + +MIT OR Apache-2.0 diff --git a/crates/ed-onde-account/src/lib.rs b/crates/ed-onde-account/src/lib.rs new file mode 100644 index 0000000..57fe3e4 --- /dev/null +++ b/crates/ed-onde-account/src/lib.rs @@ -0,0 +1,413 @@ +//! A small client for the Onde Inference account API at ondeinference.com. +//! +//! It signs a user in, finds or creates their app, activates it and returns the Onde Cloud +//! API key (`app_id:app_secret`) an agent authenticates with. Every call goes through +//! onde-web's own API, which adds the smbCloud client credentials on the server, so no +//! Onde secret ships in whatever embeds this crate. +//! +//! ```no_run +//! # async fn run() -> Result<(), ed_onde_account::Error> { +//! use ed_onde_account::{OndeAccount, SignIn}; +//! +//! let account = OndeAccount::new(); +//! if let SignIn::Ready(token) = account.sign_in("me@example.com", "password").await? { +//! let key = account.ensure_key(&token, "My Agent").await?; +//! // key.as_str() is "app_id:app_secret" +//! } +//! # Ok(()) +//! # } +//! ``` + +use std::fmt; + +use reqwest::{Method, RequestBuilder, StatusCode, Url}; +use serde::{Deserialize, Deserializer}; +use serde_json::{Value, json}; + +/// The production API host. +pub const DEFAULT_BASE_URL: &str = "https://ondeinference.com"; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The access token is missing, expired or revoked. Sign in again. + #[error("not signed in, or the session expired")] + Unauthorized, + /// Sign-in was refused: the email and password don't match. + #[error("incorrect email or password")] + InvalidCredentials, + /// Signed in, but this account may not do that. + #[error("this Onde account is not allowed to do that")] + Forbidden, + /// The user's app with this name exists but is suspended, so it cannot get a key. + #[error("the app \"{0}\" is suspended")] + Suspended(String), + /// The API answered with another failure status. + #[error("Onde API error {status}: {message}")] + Http { status: u16, message: String }, + /// The API answered, but not with the shape this client expects. + #[error("unexpected response from the Onde API: {0}")] + Unexpected(String), + /// The request never completed. + #[error("could not reach the Onde API: {0}")] + Transport(#[from] reqwest::Error), +} + +/// Which Onde backend to talk to. Production unless a host is testing against the dev stack. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum OndeEnv { + #[default] + Production, + Development, +} + +impl OndeEnv { + fn as_str(self) -> &'static str { + match self { + Self::Production => "production", + Self::Development => "development", + } + } +} + +/// The smbCloud access token for a signed-in user. Debug output is redacted. +#[derive(Clone, PartialEq, Eq)] +pub struct AccessToken(String); + +impl AccessToken { + /// Wrap a token, stripping surrounding whitespace and any `Bearer ` prefixes. + pub fn new(token: impl AsRef) -> Self { + let mut token = token.as_ref().trim(); + while let Some(prefix) = token.get(..7) { + if !prefix.eq_ignore_ascii_case("bearer ") { + break; + } + token = token[7..].trim_start(); + } + Self(token.to_owned()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for AccessToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AccessToken()") + } +} + +/// An Onde Cloud API key, `app_id:app_secret`. Debug output is redacted. +#[derive(Clone, PartialEq, Eq)] +pub struct ApiKey(String); + +impl ApiKey { + pub fn new(app_id: &str, app_secret: &str) -> Self { + Self(format!("{app_id}:{app_secret}")) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ApiKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ApiKey()") + } +} + +/// How a sign-in attempt ended. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SignIn { + Ready(AccessToken), + /// No account for this email. + NotFound, + /// The account exists but cannot sign in yet, for instance an unconfirmed email. + Incomplete { + error_code: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AppStatus { + Provisioning, + Active, + Suspended, + #[serde(other)] + Unknown, +} + +/// An Onde app, the thing an API key belongs to. +#[derive(Clone, Deserialize)] +pub struct OndeApp { + #[serde(deserialize_with = "string_or_number")] + pub id: String, + pub name: String, + pub status: AppStatus, + #[serde(default, deserialize_with = "opt_string_or_number")] + pub app_id: Option, + pub app_secret: Option, +} + +impl fmt::Debug for OndeApp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OndeApp") + .field("id", &self.id) + .field("name", &self.name) + .field("status", &self.status) + .field( + "app_secret", + &self.app_secret.as_ref().map(|_| ""), + ) + .finish() + } +} + +impl OndeApp { + /// The key for this app, if the secret is present. + pub fn api_key(&self) -> Option { + let secret = self.app_secret.as_deref().filter(|s| !s.is_empty())?; + Some(ApiKey::new( + self.app_id.as_deref().unwrap_or(&self.id), + secret, + )) + } +} + +/// The signed-in user. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Profile { + pub email: String, + pub name: Option, + /// Whether the account may create apps (and so keys). + pub can_create: bool, +} + +#[derive(Debug, Clone)] +pub struct OndeAccount { + http: reqwest::Client, + base_url: Url, + env: OndeEnv, +} + +impl Default for OndeAccount { + fn default() -> Self { + Self::new() + } +} + +impl OndeAccount { + /// Production at ondeinference.com. + pub fn new() -> Self { + Self::with_base_url(DEFAULT_BASE_URL).expect("the default base URL is valid") + } + + /// Point at another host, such as a local onde-web or a test server. + pub fn with_base_url(base_url: &str) -> Result { + let base_url = Url::parse(base_url) + .map_err(|e| Error::Unexpected(format!("invalid base URL: {e}")))?; + Ok(Self { + http: reqwest::Client::new(), + base_url, + env: OndeEnv::default(), + }) + } + + pub fn with_env(mut self, env: OndeEnv) -> Self { + self.env = env; + self + } + + pub async fn sign_in(&self, email: &str, password: &str) -> Result { + let body = json!({ "email": email.trim(), "password": password }); + let value = self + .send(self.request(Method::POST, "/api/auth/sign-in").json(&body)) + .await + // There is no token to expire yet, so a 401 here is a wrong password. + .map_err(|e| match e { + Error::Unauthorized => Error::InvalidCredentials, + e => e, + })?; + match value.get("status").and_then(Value::as_str) { + Some("ready") => match value.get("accessToken").and_then(Value::as_str) { + Some(token) if !AccessToken::new(token).as_str().is_empty() => { + Ok(SignIn::Ready(AccessToken::new(token))) + } + _ => Err(Error::Unexpected( + "sign-in was ready without a token".into(), + )), + }, + Some("notFound") => Ok(SignIn::NotFound), + Some("incomplete") => Ok(SignIn::Incomplete { + error_code: value.get("errorCode").and_then(Value::as_i64), + }), + _ => Err(Error::Unexpected("unknown sign-in status".into())), + } + } + + pub async fn me(&self, token: &AccessToken) -> Result { + let value = self + .send( + self.request(Method::GET, "/api/auth/me") + .bearer_auth(token.as_str()), + ) + .await?; + let flag = |key: &str| value.get(key).and_then(Value::as_bool); + Ok(Profile { + email: value + .get("email") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + name: value.get("name").and_then(Value::as_str).map(str::to_owned), + can_create: flag("can_create_gresiq_apps") + .or_else(|| flag("can_manage_all_gresiq_apps")) + .unwrap_or(false), + }) + } + + pub async fn apps(&self, token: &AccessToken) -> Result> { + let value = self + .send( + self.request(Method::GET, "/api/gresiq/apps") + .bearer_auth(token.as_str()), + ) + .await?; + decode(value) + } + + pub async fn create_app(&self, token: &AccessToken, name: &str) -> Result { + let value = self + .send( + self.request(Method::POST, "/api/gresiq/apps") + .bearer_auth(token.as_str()) + .json(&json!({ "gresiq_app": { "name": name } })), + ) + .await?; + decode(value) + } + + /// Move a provisioning app to `active`. Onde Cloud refuses its key until then. + pub async fn activate(&self, token: &AccessToken, id: &str) -> Result { + let value = self + .send( + self.request(Method::PATCH, &format!("/api/gresiq/apps/{id}")) + .bearer_auth(token.as_str()) + .json(&json!({ "gresiq_app": { "status": "active" } })), + ) + .await?; + decode(value) + } + + /// Reuse the user's app named `name` (or create it), activate it if needed and return + /// its key. Calls made one after another reuse the app. Nothing makes list-then-create + /// atomic, so two concurrent first calls can each create one; both then list again and + /// settle on the same (oldest) app, so they return the same key. The extra app stays in + /// the account, because this client has no delete call. + pub async fn ensure_key(&self, token: &AccessToken, name: &str) -> Result { + let apps = self.apps(token).await?; + let mut app = match pick(&apps, name) { + Some(app) => app.clone(), + // Whatever is left under this name is suspended. + None if apps.iter().any(|a| a.name == name) => { + return Err(Error::Suspended(name.to_owned())); + } + None => { + let created = self.create_app(token, name).await?; + // Another caller may have created one too: settle on the same app they do. + let apps = self.apps(token).await?; + pick(&apps, name).cloned().unwrap_or(created) + } + }; + if app.status != AppStatus::Active { + app = self.activate(token, &app.id).await?; + } + app.api_key() + .ok_or_else(|| Error::Unexpected("the app came back without a secret".into())) + } + + fn request(&self, method: Method, path: &str) -> RequestBuilder { + let mut url = self.base_url.clone(); + url.set_path(path); + url.query_pairs_mut().append_pair("env", self.env.as_str()); + self.http.request(method, url) + } + + async fn send(&self, request: RequestBuilder) -> Result { + let response = request.send().await?; + let status = response.status(); + let text = response.text().await?; + let body: Value = serde_json::from_str(&text).unwrap_or(Value::Null); + if status.is_success() { + return Ok(body); + } + Err(match status { + StatusCode::UNAUTHORIZED => Error::Unauthorized, + StatusCode::FORBIDDEN => Error::Forbidden, + _ => Error::Http { + status: status.as_u16(), + message: ["message", "error"] + .iter() + .find_map(|k| body.get(*k).and_then(Value::as_str)) + .map(str::to_owned) + .or_else(|| { + body.get("errors") + .and_then(Value::as_array) + .map(|e| { + e.iter() + .filter_map(Value::as_str) + .collect::>() + .join("; ") + }) + .filter(|m| !m.is_empty()) + }) + .unwrap_or_else(|| "request failed".to_owned()), + }, + }) + } +} + +fn decode Deserialize<'de>>(value: Value) -> Result { + serde_json::from_value(value).map_err(|e| Error::Unexpected(e.to_string())) +} + +fn string_or_number<'de, D: Deserializer<'de>>(d: D) -> Result { + match Value::deserialize(d)? { + Value::String(s) => Ok(s), + Value::Number(n) => Ok(n.to_string()), + other => Err(serde::de::Error::custom(format!( + "expected an id, got {other}" + ))), + } +} + +fn opt_string_or_number<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + match Option::::deserialize(d)? { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(Value::Number(n)) => Ok(Some(n.to_string())), + Some(other) => Err(serde::de::Error::custom(format!( + "expected an id, got {other}" + ))), + } +} + +/// The app every caller agrees on among those named `name`: an active one before a +/// provisioning one, then the lowest id (numeric when it parses). Suspended apps never win. +fn pick<'a>(apps: &'a [OndeApp], name: &str) -> Option<&'a OndeApp> { + apps.iter() + .filter(|a| a.name == name && a.status != AppStatus::Suspended) + .min_by_key(|a| { + let numeric = a.id.parse::().ok(); + ( + a.status != AppStatus::Active, + numeric.is_none(), + numeric, + a.id.as_str(), + ) + }) +} diff --git a/crates/ed-onde-account/tests/api.rs b/crates/ed-onde-account/tests/api.rs new file mode 100644 index 0000000..680a17d --- /dev/null +++ b/crates/ed-onde-account/tests/api.rs @@ -0,0 +1,267 @@ +//! The client against an in-process stand-in for onde-web's API. + +use std::sync::{Arc, Mutex}; + +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::{get, patch, post}; +use axum::{Json, Router}; +use ed_onde_account::{AccessToken, Error, OndeAccount, OndeEnv, SignIn}; +use serde_json::{Value, json}; +use std::collections::HashMap; + +const TOKEN: &str = "jwt-123"; + +#[derive(Default)] +struct Mock { + apps: Vec, + /// An app another caller creates just before this server handles a `POST`. + rival: Option, + /// Every `(method, path, env)` the server saw. + calls: Vec, +} + +type Shared = Arc>; + +fn app(id: &str, name: &str, status: &str) -> Value { + json!({ "id": id, "name": name, "status": status, "app_id": id, "app_secret": format!("sec-{id}") }) +} + +fn authorized(headers: &HeaderMap) -> bool { + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v == format!("Bearer {TOKEN}")) +} + +async fn serve(apps: Vec) -> (OndeAccount, Shared) { + let state: Shared = Arc::new(Mutex::new(Mock { + apps, + rival: None, + calls: vec![], + })); + + async fn sign_in( + State(s): State, + Query(q): Query>, + Json(body): Json, + ) -> (StatusCode, Json) { + s.lock() + .unwrap() + .calls + .push(format!("sign-in env={}", q["env"])); + if body["email"] == "wrong@x.io" { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ "message": "Invalid password." })), + ); + } + ( + StatusCode::OK, + Json(match body["email"].as_str().unwrap() { + "ready@x.io" => { + json!({ "status": "ready", "accessToken": format!("Bearer {TOKEN}") }) + } + "new@x.io" => json!({ "status": "notFound" }), + _ => json!({ "status": "incomplete", "errorCode": 100 }), + }), + ) + } + async fn me(headers: HeaderMap) -> (StatusCode, Json) { + if !authorized(&headers) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Unauthorized" })), + ); + } + ( + StatusCode::OK, + Json(json!({ "email": "ready@x.io", "name": "Ready", "can_create_gresiq_apps": true })), + ) + } + async fn list(State(s): State, headers: HeaderMap) -> (StatusCode, Json) { + if !authorized(&headers) { + return (StatusCode::UNAUTHORIZED, Json(json!({}))); + } + ( + StatusCode::OK, + Json(Value::Array(s.lock().unwrap().apps.clone())), + ) + } + async fn create(State(s): State, Json(body): Json) -> (StatusCode, Json) { + let mut s = s.lock().unwrap(); + s.calls.push("create".into()); + if let Some(rival) = s.rival.take() { + s.apps.push(rival); + } + let created = app( + "new-1", + body["gresiq_app"]["name"].as_str().unwrap(), + "provisioning", + ); + s.apps.push(created.clone()); + (StatusCode::CREATED, Json(created)) + } + async fn activate( + State(s): State, + Path(id): Path, + Json(body): Json, + ) -> Json { + let mut s = s.lock().unwrap(); + s.calls + .push(format!("activate {id} {}", body["gresiq_app"]["status"])); + let found = s.apps.iter_mut().find(|a| a["id"] == id).unwrap(); + found["status"] = json!("active"); + Json(found.clone()) + } + + let router = Router::new() + .route("/api/auth/sign-in", post(sign_in)) + .route("/api/auth/me", get(me)) + .route("/api/gresiq/apps", get(list).post(create)) + .route("/api/gresiq/apps/{id}", patch(activate)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + + let account = OndeAccount::with_base_url(&format!("http://{addr}")).unwrap(); + (account, state) +} + +#[tokio::test] +async fn sign_in_outcomes_and_token_normalization() { + let (account, state) = serve(vec![]).await; + + let SignIn::Ready(token) = account.sign_in("ready@x.io", "pw").await.unwrap() else { + panic!("expected ready"); + }; + assert_eq!(token.as_str(), TOKEN, "the Bearer prefix is stripped"); + assert_eq!(format!("{token:?}"), "AccessToken()"); + + assert_eq!( + account.sign_in("new@x.io", "pw").await.unwrap(), + SignIn::NotFound + ); + assert_eq!( + account.sign_in("unconfirmed@x.io", "pw").await.unwrap(), + SignIn::Incomplete { + error_code: Some(100) + } + ); + + assert!(matches!( + account.sign_in("wrong@x.io", "pw").await, + Err(Error::InvalidCredentials) + )); + + let dev = account.clone().with_env(OndeEnv::Development); + dev.sign_in("new@x.io", "pw").await.unwrap(); + let calls = state.lock().unwrap().calls.clone(); + assert_eq!(calls[0], "sign-in env=production"); + assert_eq!(calls[4], "sign-in env=development"); +} + +#[test] +fn access_token_strips_repeated_prefixes() { + assert_eq!(AccessToken::new(" bearer Bearer abc ").as_str(), "abc"); + assert_eq!(AccessToken::new("abc").as_str(), "abc"); +} + +#[tokio::test] +async fn me_reads_the_profile() { + let (account, _) = serve(vec![]).await; + let profile = account.me(&AccessToken::new(TOKEN)).await.unwrap(); + assert_eq!(profile.email, "ready@x.io"); + assert!(profile.can_create); +} + +#[tokio::test] +async fn ensure_key_reuses_an_active_app() { + let (account, state) = serve(vec![ + app("a1", "Other", "active"), + app("a2", "KaroKowe", "active"), + ]) + .await; + let key = account + .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") + .await + .unwrap(); + assert_eq!(key.as_str(), "a2:sec-a2"); + assert_eq!(format!("{key:?}"), "ApiKey()"); + assert!( + state.lock().unwrap().calls.is_empty(), + "nothing was created or patched" + ); +} + +#[tokio::test] +async fn ensure_key_activates_a_provisioning_app() { + let (account, state) = serve(vec![app("a1", "KaroKowe", "provisioning")]).await; + let key = account + .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") + .await + .unwrap(); + assert_eq!(key.as_str(), "a1:sec-a1"); + assert_eq!(state.lock().unwrap().calls, vec![r#"activate a1 "active""#]); +} + +#[tokio::test] +async fn ensure_key_creates_then_activates_when_none_exists() { + let (account, state) = serve(vec![app("a1", "Other", "active")]).await; + let token = AccessToken::new(TOKEN); + let key = account.ensure_key(&token, "KaroKowe").await.unwrap(); + assert_eq!(key.as_str(), "new-1:sec-new-1"); + assert_eq!( + state.lock().unwrap().calls, + vec!["create".to_owned(), r#"activate new-1 "active""#.to_owned()] + ); + // A second call finds it instead of creating another. + account.ensure_key(&token, "KaroKowe").await.unwrap(); + assert_eq!(state.lock().unwrap().calls.len(), 2); +} + +#[tokio::test] +async fn ensure_key_converges_when_a_create_races() { + let (account, state) = serve(vec![]).await; + // The other caller's create lands first, so its app has the lower id. + state.lock().unwrap().rival = Some(app("7", "KaroKowe", "provisioning")); + let key = account + .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") + .await + .unwrap(); + assert_eq!( + key.as_str(), + "7:sec-7", + "the oldest app wins, not the one just created" + ); + assert_eq!( + state.lock().unwrap().calls, + vec!["create".to_owned(), r#"activate 7 "active""#.to_owned()] + ); +} + +#[tokio::test] +async fn ensure_key_refuses_a_suspended_app() { + let (account, _) = serve(vec![app("a1", "KaroKowe", "suspended")]).await; + let err = account + .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") + .await + .unwrap_err(); + assert!(matches!(err, Error::Suspended(name) if name == "KaroKowe")); +} + +#[tokio::test] +async fn an_expired_token_is_unauthorized() { + let (account, _) = serve(vec![]).await; + let stale = AccessToken::new("expired"); + assert!(matches!(account.me(&stale).await, Err(Error::Unauthorized))); + assert!(matches!( + account.apps(&stale).await, + Err(Error::Unauthorized) + )); + assert!(matches!( + account.ensure_key(&stale, "KaroKowe").await, + Err(Error::Unauthorized) + )); +}