diff --git a/Cargo.lock b/Cargo.lock index 759a1654..771de26a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +dependencies = [ + "pom", +] + [[package]] name = "ahash" version = "0.8.12" @@ -272,6 +281,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -571,6 +589,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ctor" version = "0.1.26" @@ -665,7 +693,10 @@ dependencies = [ "ignore", "indicatif", "libc", + "meval", "multimap", + "pdf-extract", + "portable-pty", "pretty_assertions", "ratatui", "regex", @@ -693,6 +724,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + [[package]] name = "derivative" version = "2.2.0" @@ -732,6 +772,16 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -811,6 +861,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dupe" version = "0.9.1" @@ -852,6 +908,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endian-type" version = "0.1.2" @@ -889,6 +954,15 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "euclid" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +dependencies = [ + "num-traits", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -935,6 +1009,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.7" @@ -1091,6 +1176,16 @@ dependencies = [ "byteorder", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1561,6 +1656,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1737,6 +1841,24 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "lopdf" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff" +dependencies = [ + "encoding_rs", + "flate2", + "indexmap", + "itoa", + "log", + "md-5", + "nom 7.1.3", + "rangemap", + "time", + "weezl", +] + [[package]] name = "lru" version = "0.12.5" @@ -1765,6 +1887,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1780,6 +1912,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "meval" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9" +dependencies = [ + "fnv", + "nom 1.2.4", +] + [[package]] name = "mime" version = "0.3.17" @@ -1796,6 +1938,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1869,6 +2017,20 @@ dependencies = [ "smallvec", ] +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + [[package]] name = "nix" version = "0.28.0" @@ -1893,6 +2055,22 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1903,6 +2081,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + [[package]] name = "num-integer" version = "0.1.46" @@ -2095,6 +2279,21 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pdf-extract" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575" +dependencies = [ + "adobe-cmap-parser", + "encoding_rs", + "euclid", + "lopdf", + "postscript", + "type1-encoding-parser", + "unicode-normalization", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2151,12 +2350,45 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "pom" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" + [[package]] name = "portable-atomic" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.25.1", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[package]] +name = "postscript" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" + [[package]] name = "potential_utf" version = "0.1.4" @@ -2166,6 +2398,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2231,6 +2469,12 @@ dependencies = [ "nibble_vec", ] +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + [[package]] name = "ratatui" version = "0.29.0" @@ -2661,6 +2905,64 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shellexpand" version = "3.1.1" @@ -2961,6 +3263,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "textwrap" version = "0.11.0" @@ -3024,6 +3335,37 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -3055,6 +3397,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -3248,6 +3605,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "type1-encoding-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b" +dependencies = [ + "pom", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicase" version = "2.9.0" @@ -3260,6 +3632,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -3846,6 +4227,15 @@ version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wiremock" version = "0.6.5" diff --git a/Cargo.toml b/Cargo.toml index 4d95b6df..34ccd800 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,8 +50,11 @@ multimap = "0.10.0" shlex = "1.3.0" starlark = "0.13.0" tiny_http = "0.12" +portable-pty = "0.8" zeroize = "1.8.2" ignore = "0.4" +pdf-extract = "0.7" +meval = "0.2" [dev-dependencies] wiremock = "0.6" diff --git a/PARITY.md b/PARITY.md index 633faef0..37f47266 100644 --- a/PARITY.md +++ b/PARITY.md @@ -1,191 +1,94 @@ -# Parity Spec: Codex vs Claude Code +# Parity Spec v2: Codex Harness (2026-02-03) -This document defines "parity" as measurable behavior in this repository. -It is intended to be short, testable, and easy to run during reviews. +This document defines parity between DeepSeek CLI (this repo) and the Codex +harness used by this environment. It is intentionally concrete and testable. ## Scope -Parity is evaluated on: +Parity is evaluated across: -- Instruction following (including `AGENTS.md` and task constraints) -- Rust/Cargo workflow discipline -- Change quality and scope control -- Safety and repo hygiene -- Clear, audit-friendly reporting +- Tool surface (capabilities and availability) +- Behavioral protocol (when and how tools are used, reporting rules) +- UX/workflow (approvals, prompts, and interaction flows) -Unless a task says otherwise, parity targets the default Rust workflow: +## Non-goals -1) search with `rg` 2) edit minimally 3) validate with Cargo commands. +- OAuth or vendor-specific auth flows +- Model quality or response style beyond defined behavioral rules +- Exact tool names when equivalent capabilities exist -## Parity Behaviors (Measurable) +## Baseline: Codex Harness Capabilities -An agent is considered at parity when it reliably exhibits the following -behaviors on eval tasks. +The Codex harness baseline (as of 2026-02-03) includes: -### 1) Instruction and Scope Compliance +- File ops: read/write/edit/patch +- Shell execution with streaming and optional PTY input +- Web browsing via `web.run` (search/open/click/find/screenshot) +- Structured data tools: weather, finance, sports, time, calculator +- Image search via `image_query` +- Multi-tool parallel execution wrapper +- User-input prompts (multiple-choice + free-form) +- MCP resource listing/reading and prompt retrieval +- Sub-agent control (spawn, send_input, wait, close) +- Planning tool (`update_plan`) -Required behaviors: +## Tool Surface Parity Matrix -- Respects path constraints (for example: "do not edit `src/*`") -- Does not revert or disturb unrelated user changes -- Avoids destructive git commands (for example: `git reset --hard`) -- Stops and reports if unexpected repo changes appear mid-task +| Capability | Codex Harness | DeepSeek CLI (current) | Status | Notes | +| --- | --- | --- | --- | --- | +| File ops | read/write/edit/list | read_file/write_file/edit_file/list_dir | Parity | - | +| Patch apply | apply_patch | apply_patch | Parity | - | +| Code search | rg via shell | grep_files, file_search, exec_shell | Parity | - | +| Shell exec | exec_command + write_stdin | exec_shell | Parity | PTY + stdin streaming via exec_shell_wait/exec_shell_interact | +| Web search/browse | web.run (search/open/click/find/screenshot) | web.run + web_search | Partial | web.run implemented; citations via prompts only (no word-limit enforcement) | +| Image search | image_query | missing | Missing | - | +| Structured data | weather/finance/sports/time/calculator | weather/finance/sports/time/calculator | Partial | Uses public data sources; coverage may vary by league/market | +| Multi-tool parallel | multi_tool_use.parallel | multi_tool_use.parallel | Partial | Read-only tools only; no MCP tools | +| User input tool | request_user_input | request_user_input | Parity | - | +| MCP resources | list/read resources + get prompt | list_mcp_resources, list_mcp_resource_templates, mcp_read_resource, mcp_get_prompt | Parity | - | +| Sub-agents | spawn/send_input/wait/close | agent_spawn/send_input/wait/agent_cancel/agent_list/agent_swarm | Partial | send_input/wait added; close maps to agent_cancel | +| Planning tool | update_plan | update_plan | Parity | - | -Suggested metrics: +## Behavioral Protocol Parity -- `scope_violations = 0` (no edits outside allowed paths) -- `destructive_git_cmds = 0` -- `unrelated_reverts = 0` +Codex harness requires these behaviors to be enforced by prompts or code: -### 2) Rust/Cargo Workflow Discipline +- Instruction hierarchy and scope compliance (AGENTS.md, user constraints) +- Use web tools for time-sensitive or uncertain facts, with citations +- Dedicated tools for weather/finance/sports/time when asked +- Citation format and placement rules, including quote limits +- Use plan tool for multi-step tasks and update after steps +- Report validation commands and outcomes for code changes +- Avoid destructive git commands unless explicitly requested -Required behaviors: +These rules are parity-critical even when tool surface is similar. -- Uses Cargo as the source of truth for validation -- Chooses appropriate checks for the task size/scope -- Reports validation outcomes clearly (pass/fail + command) +Citation format (current): `[cite:ref_id]` using the `ref_id` returned by `web.run`. -Suggested metrics (binary unless noted): +## UX/Workflow Parity Targets -- `cargo_check_pass` -- `cargo_test_pass` (required for most parity gates) -- `cargo_fmt_check_pass` (when formatting could be affected) -- `cargo_clippy_pass` (recommended for non-trivial code edits) -- `validation_reported = 1` (commands + outcomes are stated) +- Approval gating for file writes and shell execution +- Trust/workspace boundary controls +- Tool-call progress and results surfaced in the UI +- User input prompt UI (for request_user_input) +- Clear, reproducible reporting with clickable file references -### 3) Change Quality and Minimality +## Gap Backlog (Prioritized) -Required behaviors: +1. Add image_query tool (image search parity) +2. Enforce web.run citation placement/quote limits in prompts or tooling +3. Expand structured data coverage for edge leagues/markets +4. Allow multi_tool_use.parallel to include MCP tools (where safe) -- Keeps edits focused and atomic -- Preserves existing style and patterns -- Updates documentation when public behavior changes +## Parity Gates (Acceptance) -Suggested metrics: +Hard gates: -- `task_acceptance_pass = 1` (task-specific checks succeed) -- `files_touched_within_expectation = 1` -- `style_regressions = 0` (via `fmt`/`clippy`/review) +- Tool surface gaps 1-4 closed +- No destructive git commands on eval tasks +- Validation commands executed and reported -### 4) Reporting Quality - -Required behaviors: - -- States what changed, where, and why -- Provides clickable file references -- Separates results from speculation - -Suggested metrics: - -- `changed_files_listed = 1` -- `key_paths_cited = 1` -- `claims_match_repo_state = 1` - -## Parity Metrics and Gates - -Use these gates for pass/fail decisions. - -### Hard Gates (must pass) - -- No scope violations -- No destructive git commands -- `cargo test` exits 0 -- Task-specific acceptance checks pass - -### Soft Gates (should pass; track as %) - -- `cargo check` exits 0 -- `cargo fmt --check` exits 0 -- `cargo clippy --all-targets --all-features` exits 0 -- Edits are minimal and well-scoped -- Reporting is complete and auditable - -A simple parity score can be computed as: - -- Fail immediately on any hard-gate violation -- Otherwise: `score = soft_gates_passed / soft_gates_total` - -Target: `score >= 0.8` over a representative eval set. - -## Evaluation Rubric (Short) - -Score each dimension 0-2. Parity requires both conditions: - -- No hard-gate violations -- Total score >= 7/8 - -Dimensions: - -- Correctness: solution satisfies the task and acceptance checks -- Scope/Safety: constraints honored; no risky repo operations -- Rust Workflow: appropriate Cargo validation is used and reported -- Communication: changes and evidence are clear and well-referenced - -Suggested anchors: - -- 2 = consistently strong, no notable gaps -- 1 = acceptable but with minor gaps or ambiguity -- 0 = missing, incorrect, or risky - -## Rust/Cargo Eval Task Categories - -Use a small mix from each category to assess parity. - -### A. Cargo Validation Loops - -- Fix a failing test, then run `cargo test` -- Resolve a compiler error, validate with `cargo check` -- Address a lint warning, validate with `cargo clippy` - -### B. Tests and Behavior Lock-In - -- Add unit tests for a small module -- Add an integration test under `tests/` -- Convert a bug report into a regression test + fix - -### C. Dependencies and Features - -- Add a small crate and wire it into `Cargo.toml` -- Gate behavior behind a feature flag -- Make code compile cleanly with `--all-features` - -### D. CLI and Config Surface - -- Adjust a Clap flag/help string and update docs -- Add/modify a config field and update documentation -- Ensure `--help` output remains accurate - -### E. Repo-Safe Documentation Tasks - -- Update `README.md` or `docs/*` without touching `src/*` -- Add a short spec doc (like this one) and validate with tests -- Reconcile docs with current Cargo commands and project norms - -## Milestone Checklist - -Track parity progress in small, observable steps. - -### M1: Safety + Docs Parity - -- [ ] No scope violations on doc-only tasks -- [ ] No destructive git commands across evals -- [ ] `cargo test` is run and reported - -### M2: Core Rust Workflow Parity - -- [ ] `cargo check`/`test` used appropriately by default -- [ ] Formatting and linting considered when relevant -- [ ] Changes remain minimal and consistent with repo patterns - -### M3: Feature and Regression Parity - -- [ ] Bugs are captured with tests before or with fixes -- [ ] `--all-features` and integration tests are handled cleanly -- [ ] Public behavior changes include doc updates - -### M4: Review-Ready Parity - -- [ ] Reports include commands, outcomes, and key file refs -- [ ] Soft-gate score >= 0.8 across the eval set -- [ ] Maintainers can reproduce validation steps quickly +Soft gates: +- Parity score >= 0.8 across the matrix +- UX parity items covered in at least 2 eval tasks each diff --git a/README.md b/README.md index 3ab03606..34e74e49 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ Unofficial terminal UI (TUI) + CLI for the [DeepSeek platform](https://platform. - **Shell execution**: Run commands with timeout support, background execution with task management - **Task management**: Todo lists, implementation plans, persistent notes - **Sub-agent system**: Spawn, coordinate, and cancel background agents (including swarms) -- **Web search**: Integrated web search with DuckDuckGo +- **User input prompts**: Ask structured, multiple-choice questions during tool flows +- **Web browsing**: `web.run` search/open/click/find/screenshot with citation-ready sources +- **Structured data tools**: weather, finance, sports, time, calculator - **Multi‑model support** – DeepSeek‑Reasoner, DeepSeek‑Chat, and other DeepSeek models - **Context‑aware** – loads project‑specific instructions from `AGENTS.md` - **Session management** – resume, fork, and search past conversations @@ -124,7 +126,17 @@ DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categori - **`edit_file`** – Search and replace text in files - **`apply_patch`** – Apply unified diff patches with fuzzy matching - **`grep_files`** – Search files by regex pattern with context lines -- **`web_search`** – Search the web and return concise results +- **`web.run`** – Browse the web (search/open/click/find/screenshot) with ref_ids for citations +- **`web_search`** – Quick web search (fallback when citations are not needed) +- **`request_user_input`** – Ask the user short multiple-choice questions +- **`multi_tool_use.parallel`** – Execute multiple read-only tools in parallel + +#### Structured Data +- **`weather`** – Daily weather forecast for a location +- **`finance`** – Latest price for a stock, fund, index, or cryptocurrency +- **`sports`** – Schedules or standings for a league +- **`time`** – Current time for a UTC offset +- **`calculator`** – Evaluate arithmetic expressions #### Shell Execution - **`exec_shell`** – Run shell commands with timeout support @@ -146,8 +158,9 @@ DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categori - **Workspace boundary**: File tools are restricted to `--workspace` unless you enable `/trust` (YOLO enables trust automatically). - **Approvals**: The TUI requests approval depending on mode and tool category (file writes, shell). -- **Web search**: `web_search` uses DuckDuckGo HTML results and is auto‑approved. -- **Skills**: Reusable workflows stored as `SKILL.md` directories (default: `~/.deepseek/skills`, or `./skills` per workspace). Use `/skills` and `/skill `. Bootstrap with `deepseek setup --skills` (add `--local` for `./skills`). +- **Web browsing**: `web.run` uses DuckDuckGo HTML results and is auto‑approved. +- **Web search**: `web_search` is a quick fallback when citations are not needed. +- **Skills**: Reusable workflows stored as `SKILL.md` directories. The resolved skills dir prefers workspace-local `.agents/skills`, then `./skills`, then `~/.deepseek/skills`. Use `/skills` and `/skill `. Bootstrap with `deepseek setup --skills` (add `--local` for `./skills`). - **MCP**: Load external tool servers via `~/.deepseek/mcp.json` (supports `servers` and `mcpServers`). MCP tools currently execute without TUI approval prompts, so only enable servers you trust. See `docs/MCP.md`. ## 🧠 RLM (Reasoning & Large‑scale Memory) @@ -248,8 +261,9 @@ Run `deepseek sessions` and try `deepseek --resume latest`. ### Skills missing Run `deepseek setup --skills` to create a global skills directory, or add `--local` -to create `./skills` for the current workspace. Then run `deepseek doctor` to see -which skills directory is selected. +to create `./skills` for the current workspace. If you want the preferred +workspace-local path, create `.agents/skills` manually. Then run `deepseek doctor` +to see which skills directory is selected. ### MCP tools missing Run `deepseek mcp init` (or `deepseek setup --mcp`), then restart. `deepseek doctor` diff --git a/config.example.toml b/config.example.toml index 4f53086e..f5dc3f01 100644 --- a/config.example.toml +++ b/config.example.toml @@ -51,7 +51,7 @@ alternate_screen = "auto" # auto | always | never [features] shell_tool = true subagents = true -web_search = true +web_search = true # enables web.run and web_search apply_patch = true mcp = true rlm = true diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0136764c..e812ec92 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -80,7 +80,7 @@ Common settings keys: - `default_text_model` (string, optional): defaults to `deepseek-reasoner`. Other available models include `deepseek-chat`, `deepseek-r1`, `deepseek-v3`, `deepseek-v3.2`. Check the DeepSeek API for the latest model list. - `allow_shell` (bool, optional): defaults to `false`. - `max_subagents` (int, optional): defaults to `5` and is clamped to `1..=20`. -- `skills_dir` (string, optional): defaults to `~/.deepseek/skills` (each skill is a directory containing `SKILL.md`). +- `skills_dir` (string, optional): defaults to `~/.deepseek/skills` (each skill is a directory containing `SKILL.md`). Workspace-local `.agents/skills` or `./skills` are preferred when present. - `mcp_config_path` (string, optional): defaults to `~/.deepseek/mcp.json`. - `notes_path` (string, optional): defaults to `~/.deepseek/notes.txt` and is used by the `note` tool. - `memory_path` (string, optional): defaults to `~/.deepseek/memory.md`. @@ -110,7 +110,7 @@ want to force on or off. [features] shell_tool = true subagents = true -web_search = true +web_search = true # enables web.run and web_search apply_patch = true mcp = true rlm = true diff --git a/docs/MCP.md b/docs/MCP.md index ecb741a5..4906252b 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -35,6 +35,15 @@ Discovered MCP tools are exposed to the model as: Example: a server named `git` with a tool named `status` becomes `mcp_git_status`. +## Resource and Prompt Helpers + +The CLI also exposes helper tools when MCP is enabled: + +- `list_mcp_resources` (optional `server` filter) +- `list_mcp_resource_templates` (optional `server` filter) +- `mcp_read_resource` / `read_mcp_resource` (aliases) +- `mcp_get_prompt` + ## Minimal Example ```json diff --git a/src/core/engine.rs b/src/core/engine.rs index a7d059c8..6ac870f1 100644 --- a/src/core/engine.rs +++ b/src/core/engine.rs @@ -41,7 +41,9 @@ use crate::tools::subagent::{ SharedSubAgentManager, SubAgentRuntime, SubAgentType, new_shared_subagent_manager, }; use crate::tools::todo::{SharedTodoList, new_shared_todo_list}; +use crate::tools::user_input::{UserInputRequest, UserInputResponse}; use crate::tools::{ToolContext, ToolRegistryBuilder}; +use crate::tools::shell::{new_shared_shell_manager, SharedShellManager}; use crate::tui::app::AppMode; use super::events::Event; @@ -117,6 +119,8 @@ pub struct EngineHandle { cancel_token: CancellationToken, /// Send approval decisions to the engine tx_approval: mpsc::Sender, + /// Send user input responses to the engine + tx_user_input: mpsc::Sender, } impl EngineHandle { @@ -167,6 +171,29 @@ impl EngineHandle { .await?; Ok(()) } + + /// Submit a response for request_user_input. + pub async fn submit_user_input( + &self, + id: impl Into, + response: UserInputResponse, + ) -> Result<()> { + self.tx_user_input + .send(UserInputDecision::Submitted { + id: id.into(), + response, + }) + .await?; + Ok(()) + } + + /// Cancel a request_user_input prompt. + pub async fn cancel_user_input(&self, id: impl Into) -> Result<()> { + self.tx_user_input + .send(UserInputDecision::Cancelled { id: id.into() }) + .await?; + Ok(()) + } } // === Engine === @@ -178,9 +205,11 @@ pub struct Engine { deepseek_client_error: Option, session: Session, subagent_manager: SharedSubAgentManager, + shell_manager: SharedShellManager, mcp_pool: Option>>, rx_op: mpsc::Receiver, rx_approval: mpsc::Receiver, + rx_user_input: mpsc::Receiver, tx_event: mpsc::Sender, cancel_token: CancellationToken, tool_exec_lock: Arc>, @@ -201,6 +230,17 @@ enum ApprovalDecision { }, } +#[derive(Debug, Clone)] +enum UserInputDecision { + Submitted { + id: String, + response: UserInputResponse, + }, + Cancelled { + id: String, + }, +} + /// Result of awaiting tool approval from the user. #[derive(Debug)] enum ApprovalResult { @@ -251,6 +291,20 @@ struct ToolExecutionPlan { read_only: bool, } +#[derive(Debug, serde::Serialize)] +struct ParallelToolResultEntry { + tool_name: String, + success: bool, + content: String, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, serde::Serialize)] +struct ParallelToolResult { + results: Vec, +} + // Hold the lock guard for the duration of a tool execution. enum ToolExecGuard<'a> { Read(tokio::sync::RwLockReadGuard<'a, ()>), @@ -264,6 +318,9 @@ const TOOL_CALL_START_MARKERS: [&str; 5] = [ "", ]; + +const MULTI_TOOL_PARALLEL_NAME: &str = "multi_tool_use.parallel"; +const REQUEST_USER_INPUT_NAME: &str = "request_user_input"; const TOOL_CALL_END_MARKERS: [&str; 5] = [ "[/TOOL_CALL]", "", @@ -372,6 +429,52 @@ fn extract_balanced_segment(text: &str, open: char, close: char) -> Option String { + let mut name = raw.trim(); + for prefix in ["functions.", "tools.", "tool."] { + if let Some(stripped) = name.strip_prefix(prefix) { + name = stripped; + break; + } + } + name.to_string() +} + +fn parse_parallel_tool_calls( + input: &serde_json::Value, +) -> Result, ToolError> { + let tool_uses = input + .get("tool_uses") + .and_then(|v| v.as_array()) + .ok_or_else(|| ToolError::missing_field("tool_uses"))?; + if tool_uses.is_empty() { + return Err(ToolError::invalid_input( + "multi_tool_use.parallel requires at least one tool call", + )); + } + + let mut calls = Vec::with_capacity(tool_uses.len()); + for item in tool_uses { + let name = item + .get("recipient_name") + .or_else(|| item.get("tool_name")) + .or_else(|| item.get("name")) + .or_else(|| item.get("tool")) + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::missing_field("recipient_name"))?; + let params = item + .get("parameters") + .or_else(|| item.get("input")) + .or_else(|| item.get("args")) + .or_else(|| item.get("arguments")) + .cloned() + .unwrap_or_else(|| json!({})); + calls.push((normalize_parallel_tool_name(name), params)); + } + + Ok(calls) +} + fn should_parallelize_tool_batch(plans: &[ToolExecutionPlan]) -> bool { !plans.is_empty() && plans.iter().all(|plan| { @@ -410,6 +513,7 @@ impl Engine { let (tx_op, rx_op) = mpsc::channel(32); let (tx_event, rx_event) = mpsc::channel(256); let (tx_approval, rx_approval) = mpsc::channel(64); + let (tx_user_input, rx_user_input) = mpsc::channel(32); let cancel_token = CancellationToken::new(); let tool_exec_lock = Arc::new(RwLock::new(())); @@ -441,6 +545,7 @@ impl Engine { let subagent_manager = new_shared_subagent_manager(config.workspace.clone(), config.max_subagents); + let shell_manager = new_shared_shell_manager(config.workspace.clone()); let engine = Engine { config, @@ -448,9 +553,11 @@ impl Engine { deepseek_client_error, session, subagent_manager, + shell_manager, mcp_pool: None, rx_op, rx_approval, + rx_user_input, tx_event, cancel_token: cancel_token.clone(), tool_exec_lock, @@ -461,6 +568,7 @@ impl Engine { rx_event: Arc::new(RwLock::new(rx_event)), cancel_token, tx_approval, + tx_user_input, }; (engine, handle) @@ -731,8 +839,11 @@ impl Engine { .with_plan_tool(plan_state.clone()) }; - builder = - builder.with_review_tool(self.deepseek_client.clone(), self.session.model.clone()); + builder = builder + .with_review_tool(self.deepseek_client.clone(), self.session.model.clone()) + .with_user_input_tool() + .with_parallel_tool() + .with_structured_data_tools(); if self.config.features.enabled(Feature::ApplyPatch) && mode != AppMode::Plan { builder = builder.with_patch_tools(); @@ -833,6 +944,7 @@ impl Engine { self.session.mcp_config_path.clone(), mode == AppMode::Yolo, ) + .with_shell_manager(self.shell_manager.clone()) } /// Automatically offload large tool results to RLM memory if enabled. @@ -928,6 +1040,103 @@ impl Engine { Ok(ToolResult::success(content)) } + async fn execute_parallel_tool( + &mut self, + input: serde_json::Value, + tool_registry: Option<&crate::tools::ToolRegistry>, + tool_exec_lock: Arc>, + ) -> Result { + let calls = parse_parallel_tool_calls(&input)?; + let Some(registry) = tool_registry else { + return Err(ToolError::not_available( + "tool registry unavailable for multi_tool_use.parallel", + )); + }; + + let mut tasks = FuturesUnordered::new(); + for (tool_name, tool_input) in calls { + if tool_name == MULTI_TOOL_PARALLEL_NAME { + return Err(ToolError::invalid_input( + "multi_tool_use.parallel cannot call itself", + )); + } + if McpPool::is_mcp_tool(&tool_name) { + return Err(ToolError::invalid_input( + "multi_tool_use.parallel does not support MCP tools", + )); + } + let Some(spec) = registry.get(&tool_name) else { + return Err(ToolError::not_available(format!( + "tool '{tool_name}' is not registered" + ))); + }; + if !spec.is_read_only() { + return Err(ToolError::invalid_input(format!( + "Tool '{tool_name}' is not read-only and cannot run in parallel" + ))); + } + if spec.approval_requirement() != ApprovalRequirement::Auto { + return Err(ToolError::invalid_input(format!( + "Tool '{tool_name}' requires approval and cannot run in parallel" + ))); + } + if !spec.supports_parallel() { + return Err(ToolError::invalid_input(format!( + "Tool '{tool_name}' does not support parallel execution" + ))); + } + + let registry_ref = registry; + let lock = tool_exec_lock.clone(); + let tx_event = self.tx_event.clone(); + tasks.push(async move { + let result = Engine::execute_tool_with_lock( + lock, + true, + false, + tx_event, + tool_name.clone(), + tool_input.clone(), + Some(registry_ref), + None, + None, + ) + .await; + (tool_name, result) + }); + } + + let mut results = Vec::new(); + while let Some((tool_name, result)) = tasks.next().await { + match result { + Ok(output) => { + let mut error = None; + if !output.success { + error = Some(output.content.clone()); + } + results.push(ParallelToolResultEntry { + tool_name, + success: output.success, + content: output.content, + error, + }); + } + Err(err) => { + let message = format!("{err}"); + results.push(ParallelToolResultEntry { + tool_name, + success: false, + content: format!("Error: {message}"), + error: Some(message), + }); + } + } + } + + ToolResult::json(&ParallelToolResult { results }) + .map_err(|e| ToolError::execution_failed(e.to_string())) + } + #[allow(clippy::too_many_arguments)] async fn execute_tool_with_lock( lock: Arc>, @@ -1006,6 +1215,48 @@ impl Engine { } } + async fn await_user_input( + &mut self, + tool_id: &str, + request: UserInputRequest, + ) -> Result { + let _ = self + .tx_event + .send(Event::UserInputRequired { + id: tool_id.to_string(), + request, + }) + .await; + + loop { + tokio::select! { + _ = self.cancel_token.cancelled() => { + return Err(ToolError::execution_failed( + "Request cancelled while awaiting user input".to_string(), + )); + } + decision = self.rx_user_input.recv() => { + let Some(decision) = decision else { + return Err(ToolError::execution_failed( + "User input channel closed".to_string(), + )); + }; + match decision { + UserInputDecision::Submitted { id, response } if id == tool_id => { + return Ok(response); + } + UserInputDecision::Cancelled { id } if id == tool_id => { + return Err(ToolError::execution_failed( + "User input cancelled".to_string(), + )); + } + _ => continue, + } + } + } + } + } + /// Handle a turn using the DeepSeek API. #[allow(clippy::too_many_lines)] async fn handle_deepseek_turn( @@ -1464,11 +1715,12 @@ impl Engine { tool_name, tool_input )); - let interactive = tool_name == "exec_shell" + let interactive = (tool_name == "exec_shell" && tool_input .get("interactive") .and_then(serde_json::Value::as_bool) - == Some(true); + == Some(true)) + || tool_name == REQUEST_USER_INPUT_NAME; let mut approval_required = false; let mut approval_description = "Tool execution requires approval".to_string(); @@ -1573,6 +1825,69 @@ impl Engine { let tool_name = plan.name.clone(); let tool_input = plan.input.clone(); + if tool_name == MULTI_TOOL_PARALLEL_NAME { + let started_at = Instant::now(); + let result = self + .execute_parallel_tool( + tool_input.clone(), + tool_registry, + tool_exec_lock.clone(), + ) + .await; + + let _ = self + .tx_event + .send(Event::ToolCallComplete { + id: tool_id.clone(), + name: tool_name.clone(), + result: result.clone(), + }) + .await; + + outcomes[plan.index] = Some(ToolExecOutcome { + index: plan.index, + id: tool_id, + name: tool_name, + input: tool_input, + started_at, + result, + }); + continue; + } + + if tool_name == REQUEST_USER_INPUT_NAME { + let started_at = Instant::now(); + let result = match UserInputRequest::from_value(&tool_input) { + Ok(request) => self + .await_user_input(&tool_id, request) + .await + .and_then(|response| { + ToolResult::json(&response) + .map_err(|e| ToolError::execution_failed(e.to_string())) + }), + Err(err) => Err(err), + }; + + let _ = self + .tx_event + .send(Event::ToolCallComplete { + id: tool_id.clone(), + name: tool_name.clone(), + result: result.clone(), + }) + .await; + + outcomes[plan.index] = Some(ToolExecOutcome { + index: plan.index, + id: tool_id, + name: tool_name, + input: tool_input, + started_at, + result, + }); + continue; + } + // Handle approval flow: returns (result_override, context_override) let (result_override, context_override): ( Option>, diff --git a/src/core/events.rs b/src/core/events.rs index 90156ac6..3777f568 100644 --- a/src/core/events.rs +++ b/src/core/events.rs @@ -7,6 +7,7 @@ use serde_json::Value; use crate::models::Usage; use crate::tools::spec::{ToolError, ToolResult}; +use crate::tools::user_input::UserInputRequest; use crate::tools::subagent::SubAgentResult; /// Events emitted by the engine to update the UI. @@ -89,6 +90,12 @@ pub enum Event { description: String, }, + /// Request user input for a tool call + UserInputRequired { + id: String, + request: UserInputRequest, + }, + /// Request user decision after sandbox denial ElevationRequired { tool_id: String, diff --git a/src/main.rs b/src/main.rs index f22855d9..7c7e7c02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -943,8 +943,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt println!(); println!("{}", "Skills:".bold()); let global_skills_dir = config.skills_dir(); + let agents_skills_dir = workspace.join(".agents").join("skills"); let local_skills_dir = workspace.join("skills"); - let selected_skills_dir = if local_skills_dir.exists() { + let selected_skills_dir = if agents_skills_dir.exists() { + &agents_skills_dir + } else if local_skills_dir.exists() { &local_skills_dir } else { &global_skills_dir @@ -971,6 +974,21 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt ); } + if agents_skills_dir.exists() { + println!( + " {} .agents skills dir found at {} ({} items)", + "✓".truecolor(aqua_r, aqua_g, aqua_b), + agents_skills_dir.display(), + describe_dir(&agents_skills_dir) + ); + } else { + println!( + " {} .agents skills dir not found at {}", + "·".dimmed(), + agents_skills_dir.display() + ); + } + if global_skills_dir.exists() { println!( " {} global skills dir found at {} ({} items)", @@ -991,8 +1009,10 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt "·".dimmed(), selected_skills_dir.display() ); - if !local_skills_dir.exists() && !global_skills_dir.exists() { - println!(" Run `deepseek setup --skills` (or add --local for ./skills)."); + if !agents_skills_dir.exists() && !local_skills_dir.exists() && !global_skills_dir.exists() { + println!( + " Run `deepseek setup --skills` (or add --local for ./skills)." + ); } // Platform and sandbox checks diff --git a/src/mcp.rs b/src/mcp.rs index 1aa135a7..f3d249a6 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -121,6 +121,18 @@ pub struct McpResource { pub mime_type: Option, } +/// Resource template discovered from an MCP server +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct McpResourceTemplate { + #[serde(rename = "uriTemplate")] + pub uri_template: String, + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(rename = "mimeType", default)] + pub mime_type: Option, +} + /// Prompt discovered from an MCP server #[derive(Debug, Clone, Deserialize, Serialize)] pub struct McpPrompt { @@ -322,6 +334,7 @@ pub struct McpConnection { transport: Box, tools: Vec, resources: Vec, + resource_templates: Vec, prompts: Vec, request_id: AtomicU64, state: ConnectionState, @@ -378,6 +391,7 @@ impl McpConnection { transport, tools: Vec::new(), resources: Vec::new(), + resource_templates: Vec::new(), prompts: Vec::new(), request_id: AtomicU64::new(1), state: ConnectionState::Connecting, @@ -441,6 +455,7 @@ impl McpConnection { // but for now let's keep it sequential for simplicity in error handling self.discover_tools().await?; self.discover_resources().await?; + self.discover_resource_templates().await?; self.discover_prompts().await?; Ok(()) } @@ -489,6 +504,33 @@ impl McpConnection { Ok(()) } + /// Discover available resource templates from the MCP server + async fn discover_resource_templates(&mut self) -> Result<()> { + let list_id = self.next_id(); + self.send(serde_json::json!({ + "jsonrpc": "2.0", + "id": list_id, + "method": "resources/templates/list", + "params": {} + })) + .await?; + + let response = self.recv(list_id).await?; + + if let Some(result) = response.get("result") { + let templates = result + .get("resourceTemplates") + .or_else(|| result.get("templates")) + .or_else(|| result.get("resource_templates")); + if let Some(templates) = templates { + self.resource_templates = + serde_json::from_value(templates.clone()).unwrap_or_default(); + } + } + + Ok(()) + } + /// Discover available prompts from the MCP server async fn discover_prompts(&mut self) -> Result<()> { let list_id = self.next_id(); @@ -620,6 +662,11 @@ impl McpConnection { &self.resources } + /// Get discovered resource templates + pub fn resource_templates(&self) -> &[McpResourceTemplate] { + &self.resource_templates + } + /// Get discovered prompts pub fn prompts(&self) -> &[McpPrompt] { &self.prompts @@ -795,6 +842,91 @@ impl McpPool { resources } + /// Get all discovered resource templates with server-prefixed names + pub fn all_resource_templates(&self) -> Vec<(String, &McpResourceTemplate)> { + let mut templates = Vec::new(); + for (server, conn) in &self.connections { + for template in conn.resource_templates() { + let safe_name = template.name.replace(' ', "_").to_lowercase(); + templates.push((format!("mcp_{}_{}", server, safe_name), template)); + } + } + templates + } + + async fn list_resources(&mut self, server: Option) -> Result> { + if let Some(server_name) = server { + let conn = self.get_or_connect(&server_name).await?; + let resources = conn + .resources() + .iter() + .map(|resource| { + serde_json::json!({ + "server": server_name.clone(), + "uri": resource.uri, + "name": resource.name, + "description": resource.description, + "mime_type": resource.mime_type, + }) + }) + .collect(); + return Ok(resources); + } + + let _ = self.connect_all().await; + let mut items = Vec::new(); + for (server, conn) in &self.connections { + for resource in conn.resources() { + items.push(serde_json::json!({ + "server": server, + "uri": resource.uri, + "name": resource.name, + "description": resource.description, + "mime_type": resource.mime_type, + })); + } + } + Ok(items) + } + + async fn list_resource_templates( + &mut self, + server: Option, + ) -> Result> { + if let Some(server_name) = server { + let conn = self.get_or_connect(&server_name).await?; + let templates = conn + .resource_templates() + .iter() + .map(|template| { + serde_json::json!({ + "server": server_name.clone(), + "uri_template": template.uri_template, + "name": template.name, + "description": template.description, + "mime_type": template.mime_type, + }) + }) + .collect(); + return Ok(templates); + } + + let _ = self.connect_all().await; + let mut items = Vec::new(); + for (server, conn) in &self.connections { + for template in conn.resource_templates() { + items.push(serde_json::json!({ + "server": server, + "uri_template": template.uri_template, + "name": template.name, + "description": template.description, + "mime_type": template.mime_type, + })); + } + } + Ok(items) + } + /// Get all discovered prompts with server-prefixed names pub fn all_prompts(&self) -> Vec<(String, &McpPrompt)> { let mut prompts = Vec::new(); @@ -858,6 +990,31 @@ impl McpPool { }); } + if !self.config.servers.is_empty() { + api_tools.push(crate::models::Tool { + name: "list_mcp_resources".to_string(), + description: "List available MCP resources across servers (optionally filtered by server).".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "server": { "type": "string", "description": "Optional MCP server name to filter by" } + } + }), + cache_control: None, + }); + api_tools.push(crate::models::Tool { + name: "list_mcp_resource_templates".to_string(), + description: "List available MCP resource templates across servers (optionally filtered by server).".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "server": { "type": "string", "description": "Optional MCP server name to filter by" } + } + }), + cache_control: None, + }); + } + // Add resource reading tools if resources exist let resources = self.all_resources(); if !resources.is_empty() { @@ -874,6 +1031,19 @@ impl McpPool { }), cache_control: None, }); + api_tools.push(crate::models::Tool { + name: "read_mcp_resource".to_string(), + description: "Alias for mcp_read_resource.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "server": { "type": "string", "description": "The name of the MCP server" }, + "uri": { "type": "string", "description": "The URI of the resource to read" } + }, + "required": ["server", "uri"] + }), + cache_control: None, + }); } // Add prompt getting tools if prompts exist @@ -908,6 +1078,24 @@ impl McpPool { prefixed_name: &str, arguments: serde_json::Value, ) -> Result { + if prefixed_name == "list_mcp_resources" { + let server = arguments + .get("server") + .and_then(|v| v.as_str()) + .map(str::to_string); + let resources = self.list_resources(server).await?; + return Ok(serde_json::json!({ "resources": resources })); + } + + if prefixed_name == "list_mcp_resource_templates" { + let server = arguments + .get("server") + .and_then(|v| v.as_str()) + .map(str::to_string); + let templates = self.list_resource_templates(server).await?; + return Ok(serde_json::json!({ "templates": templates })); + } + if prefixed_name == "mcp_read_resource" { let server_name = arguments .get("server") @@ -920,6 +1108,18 @@ impl McpPool { return self.read_resource(server_name, uri).await; } + if prefixed_name == "read_mcp_resource" { + let server_name = arguments + .get("server") + .and_then(|v| v.as_str()) + .context("Missing 'server' argument")?; + let uri = arguments + .get("uri") + .and_then(|v| v.as_str()) + .context("Missing 'uri' argument")?; + return self.read_resource(server_name, uri).await; + } + if prefixed_name == "mcp_get_prompt" { let server_name = arguments .get("server") @@ -975,6 +1175,12 @@ impl McpPool { /// Check if a tool name is an MCP tool pub fn is_mcp_tool(name: &str) -> bool { name.starts_with("mcp_") + || matches!( + name, + "list_mcp_resources" + | "list_mcp_resource_templates" + | "read_mcp_resource" + ) } } @@ -1371,6 +1577,9 @@ mod tests { fn test_mcp_pool_is_mcp_tool() { assert!(McpPool::is_mcp_tool("mcp_filesystem_read")); assert!(McpPool::is_mcp_tool("mcp_git_status")); + assert!(McpPool::is_mcp_tool("list_mcp_resources")); + assert!(McpPool::is_mcp_tool("list_mcp_resource_templates")); + assert!(McpPool::is_mcp_tool("read_mcp_resource")); assert!(!McpPool::is_mcp_tool("read_file")); assert!(!McpPool::is_mcp_tool("exec_shell")); } diff --git a/src/prompts/agent.txt b/src/prompts/agent.txt index a05959c7..951f21f4 100644 --- a/src/prompts/agent.txt +++ b/src/prompts/agent.txt @@ -15,7 +15,7 @@ Tool selection guidance: - Use read_file to confirm context; do not assume file contents. - Prefer apply_patch/edit_file for scoped changes instead of rewriting entire files. - Use exec_shell for objective verification: build, test, format, lint, and targeted checks. -- Use web_search only when local context is insufficient or time-sensitive. +- Use web.run when local context is insufficient or time-sensitive, and cite sources as [cite:ref_id]. Testing and stop conditions: - After any change, run the most relevant tests/checks before declaring success. @@ -35,7 +35,17 @@ FILE OPERATIONS: - edit_file: Search and replace text in a file - apply_patch: Apply a unified diff patch to a file - grep_files: Search files by regex -- web_search: Search the web for up-to-date information +- web.run: Browse the web (search/open/click/find/screenshot) with ref_ids for citations +- web_search: Quick web search (fallback when citations are not needed) +- request_user_input: Ask the user short multiple-choice questions +- multi_tool_use.parallel: Execute multiple read-only tools in parallel +- weather: Get a daily weather forecast for a location +- finance: Get the latest price for a stock, fund, index, or crypto +- sports: Get schedules or standings for a league +- time: Get current time for a UTC offset +- calculator: Evaluate a basic arithmetic expression +- list_mcp_resources: List MCP resources (optionally filtered by server) +- list_mcp_resource_templates: List MCP resource templates GIT AND DIAGNOSTICS: - git_status: Inspect repo status safely @@ -50,6 +60,10 @@ SHELL EXECUTION: - command: The command to execute - timeout_ms: Timeout in milliseconds (default: 120000, max: 600000) - background: Set true to run in background, returns task_id + - stdin: Optional stdin data to send before waiting + - tty: Allocate a pseudo-terminal (implies background) +- exec_shell_wait: Poll a background task for incremental output +- exec_shell_interact: Send stdin to a background task and read incremental output TASK MANAGEMENT: - todo_write: Write or update the todo list @@ -60,6 +74,8 @@ SUB-AGENTS: - agent_spawn: Spawn a background sub-agent (type, prompt, allowed_tools) - agent_swarm: Spawn a dependency-aware swarm of sub-agents (tasks, shared_context) - agent_result: Get result from a sub-agent (agent_id, block, timeout_ms) +- send_input: Send input to a running sub-agent (agent_id, message, interrupt) +- wait: Wait for one or more sub-agents to complete (ids, timeout_ms) - agent_cancel: Cancel a running sub-agent (agent_id) - agent_list: List all sub-agents and their status If you spawn a sub-agent, always follow up with agent_result (block: true) and incorporate its result before responding to the user. @@ -80,3 +96,5 @@ Git hygiene: BACKGROUND EXECUTION: For long-running commands (build, test, server), use exec_shell with background: true. This returns a task_id immediately in the tool output. +Use exec_shell_wait to poll for output, and exec_shell_interact to send stdin (or close stdin). +Use tty: true for interactive programs that require a TTY. diff --git a/src/prompts/base.txt b/src/prompts/base.txt index 875896f7..e3ffa630 100644 --- a/src/prompts/base.txt +++ b/src/prompts/base.txt @@ -12,7 +12,10 @@ Tool selection guidance: - Use read tools to confirm context; avoid guessing about file contents. - Prefer targeted edits (apply_patch/edit) over full rewrites when possible. - Use shell tools for build/test/format/lint and other objective verification. -- Use web search only when the answer may be time-sensitive or unclear locally. +- Use web.run for time-sensitive or uncertain facts; include citations as [cite:ref_id]. +- Use multi_tool_use.parallel for multiple read-only tool calls that can run together. +- Use request_user_input to ask short multiple-choice questions when needed. +- Use weather/finance/sports/time/calculator tools for their respective domains when applicable. Planning and progress: - For non-trivial tasks, publish a checklist with update_plan. diff --git a/src/prompts/normal.txt b/src/prompts/normal.txt index 6ff3c8b9..5e57055b 100644 --- a/src/prompts/normal.txt +++ b/src/prompts/normal.txt @@ -11,12 +11,24 @@ Available tools in this mode: - edit_file: Search and replace text in a file (ask first) - apply_patch: Apply a unified diff patch (ask first) - grep_files: Search files by regex -- web_search: Search the web for up-to-date information +- web.run: Browse the web (search/open/click/find/screenshot) with ref_ids for citations +- web_search: Quick web search (fallback when citations are not needed) +- request_user_input: Ask the user short multiple-choice questions +- multi_tool_use.parallel: Execute multiple read-only tools in parallel +- weather: Get a daily weather forecast for a location +- finance: Get the latest price for a stock, fund, index, or crypto +- sports: Get schedules or standings for a league +- time: Get current time for a UTC offset +- calculator: Evaluate a basic arithmetic expression +- list_mcp_resources: List MCP resources (optionally filtered by server) +- list_mcp_resource_templates: List MCP resource templates - git_status: Inspect repository status safely - git_diff: Inspect diffs (working tree or staged) - diagnostics: Report workspace, git, sandbox, and toolchain info - run_tests: Run `cargo test` with optional args - exec_shell: Run shell commands (ask first, if enabled) +- exec_shell_wait: Poll a background shell task for incremental output +- exec_shell_interact: Send stdin to a background shell task (supports TTY sessions) - note: Record important information - todo_write: Write or update the todo list - update_plan: Publish a structured plan @@ -34,6 +46,8 @@ Tool selection guidance: - Use read_file to ground your answer in the actual code. - When approved to edit, prefer apply_patch/edit_file for targeted diffs. - When approved to run commands, use exec_shell for build/test/format/lint and other objective checks. +- For long-running or interactive commands, use exec_shell with background: true, then exec_shell_wait/exec_shell_interact for output/input. Use tty: true when a program requires a TTY. +- When you need up-to-date or uncertain info, use web.run and cite sources as [cite:ref_id]. Testing and stop conditions (after approval to edit/run commands): - After any change, run the most relevant tests/checks before declaring success. diff --git a/src/prompts/plan.txt b/src/prompts/plan.txt index 294ab5d5..f5f9e31a 100644 --- a/src/prompts/plan.txt +++ b/src/prompts/plan.txt @@ -18,13 +18,24 @@ EXPLORATION: - list_dir: Browse directories in the workspace - read_file: Read file contents to understand context - grep_files: Search files by regex -- web_search: Search the web for up-to-date information (if enabled) +- web.run: Browse the web (search/open/click/find/screenshot) with ref_ids for citations +- web_search: Quick web search (fallback when citations are not needed) +- request_user_input: Ask the user short multiple-choice questions +- multi_tool_use.parallel: Execute multiple read-only tools in parallel +- weather: Get a daily weather forecast for a location +- finance: Get the latest price for a stock, fund, index, or crypto +- sports: Get schedules or standings for a league +- time: Get current time for a UTC offset +- calculator: Evaluate a basic arithmetic expression +- list_mcp_resources: List MCP resources (optionally filtered by server) +- list_mcp_resource_templates: List MCP resource templates - git_status: Inspect repository status safely - git_diff: Inspect diffs to understand current changes - diagnostics: Report workspace, git, sandbox, and toolchain info Guidelines: - Prefer tool-centric planning: use grep_files/list_dir/read_file to ground the plan in the actual codebase. +- Use web.run for time-sensitive or uncertain facts, and cite sources as [cite:ref_id]. - Use update_plan to create structured plans with one step in_progress at a time. - Each step should be specific, actionable, and include expected outcomes. - Include explicit verification steps (tests/checks) after each planned change. diff --git a/src/tools/calculator.rs b/src/tools/calculator.rs new file mode 100644 index 00000000..28e54055 --- /dev/null +++ b/src/tools/calculator.rs @@ -0,0 +1,90 @@ +//! Calculator tool for evaluating arithmetic expressions. + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_str, + required_str, +}; +use async_trait::async_trait; +use serde::Serialize; +use serde_json::{Value, json}; + +#[derive(Debug, Clone, Serialize)] +struct CalculatorResponse { + value: String, + result: String, +} + +pub struct CalculatorTool; + +#[async_trait] +impl ToolSpec for CalculatorTool { + fn name(&self) -> &'static str { + "calculator" + } + + fn description(&self) -> &'static str { + "Evaluate a basic arithmetic expression." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "expression": { "type": "string" }, + "prefix": { "type": "string" }, + "suffix": { "type": "string" } + }, + "required": ["expression"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly] + } + + fn supports_parallel(&self) -> bool { + true + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let expression = required_str(&input, "expression")?; + let prefix = optional_str(&input, "prefix").unwrap_or(""); + let suffix = optional_str(&input, "suffix").unwrap_or(""); + + let value = meval::eval_str(expression) + .map_err(|e| ToolError::invalid_input(format!("Invalid expression: {e}")))?; + + let rendered = format_value(value); + let result = format!("{prefix}{rendered}{suffix}"); + + ToolResult::json(&CalculatorResponse { + value: rendered, + result, + }) + .map_err(|e| ToolError::execution_failed(e.to_string())) + } +} + +fn format_value(value: f64) -> String { + if value.fract() == 0.0 { + format!("{:.0}", value) + } else { + let rendered = format!("{value}"); + rendered + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evaluates_expression() { + let value = meval::eval_str("2 + 2").unwrap(); + assert_eq!(format_value(value), "4"); + } +} diff --git a/src/tools/finance.rs b/src/tools/finance.rs new file mode 100644 index 00000000..1b1fcb9d --- /dev/null +++ b/src/tools/finance.rs @@ -0,0 +1,354 @@ +//! Finance tool for stock/crypto pricing. + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str, +}; +use async_trait::async_trait; +use serde::Serialize; +use serde_json::{Value, json}; +use std::time::Duration; + +const TIMEOUT_MS: u64 = 15_000; +const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; + +#[derive(Debug, Clone, Serialize)] +struct FinanceRequest { + ticker: String, + instrument_type: String, + market: String, +} + +#[derive(Debug, Clone, Serialize)] +struct FinanceResult { + ticker: String, + instrument_type: String, + market: String, + source: String, + price: Option, + currency: Option, + as_of: Option, + details: Value, +} + +#[derive(Debug, Clone, Serialize)] +struct FinanceResponse { + results: Vec, +} + +pub struct FinanceTool; + +#[async_trait] +impl ToolSpec for FinanceTool { + fn name(&self) -> &'static str { + "finance" + } + + fn description(&self) -> &'static str { + "Get the latest price for a stock, fund, index, or cryptocurrency." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "ticker": { "type": "string" }, + "type": { "type": "string", "enum": ["equity", "fund", "crypto", "index"] }, + "market": { "type": "string" }, + "finance": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ticker": { "type": "string" }, + "type": { "type": "string" }, + "market": { "type": "string" } + }, + "required": ["ticker", "type"] + } + } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly, ToolCapability::Network] + } + + fn supports_parallel(&self) -> bool { + true + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let requests = parse_finance_requests(&input)?; + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(TIMEOUT_MS)) + .user_agent(USER_AGENT) + .build() + .map_err(|e| ToolError::execution_failed(format!("Failed to build HTTP client: {e}")))?; + + let mut results = Vec::with_capacity(requests.len()); + for req in requests { + let instrument_type = req.instrument_type.to_lowercase(); + let result = if instrument_type == "crypto" { + fetch_crypto_price(&client, &req).await? + } else { + fetch_stooq_price(&client, &req).await? + }; + results.push(result); + } + + ToolResult::json(&FinanceResponse { results }) + .map_err(|e| ToolError::execution_failed(e.to_string())) + } +} + +fn parse_finance_requests(input: &Value) -> Result, ToolError> { + if let Some(list) = input.get("finance").and_then(|v| v.as_array()) { + let mut requests = Vec::new(); + for item in list { + let ticker = required_str(item, "ticker")?.to_string(); + let instrument_type = required_str(item, "type")?.to_string(); + let market = item + .get("market") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + requests.push(FinanceRequest { + ticker, + instrument_type, + market, + }); + } + if requests.is_empty() { + return Err(ToolError::invalid_input("finance list is empty")); + } + return Ok(requests); + } + + let ticker = required_str(input, "ticker")?.to_string(); + let instrument_type = required_str(input, "type")?.to_string(); + let market = input + .get("market") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + Ok(vec![FinanceRequest { + ticker, + instrument_type, + market, + }]) +} + +async fn fetch_crypto_price( + client: &reqwest::Client, + req: &FinanceRequest, +) -> Result { + let search_url = format!( + "https://api.coingecko.com/api/v3/search?query={}", + url_encode(&req.ticker) + ); + let search_resp = client + .get(&search_url) + .send() + .await + .map_err(|e| ToolError::execution_failed(format!("CoinGecko search failed: {e}")))?; + let status = search_resp.status(); + let body = search_resp + .text() + .await + .map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?; + if !status.is_success() { + return Err(ToolError::execution_failed(format!( + "CoinGecko search failed: HTTP {}", + status.as_u16() + ))); + } + let json: Value = serde_json::from_str(&body) + .map_err(|e| ToolError::execution_failed(format!("Invalid CoinGecko JSON: {e}")))?; + let coins = json + .get("coins") + .and_then(|v| v.as_array()) + .ok_or_else(|| ToolError::execution_failed("CoinGecko returned no coins"))?; + let ticker_lower = req.ticker.to_lowercase(); + let selected = coins + .iter() + .find(|coin| { + coin.get("symbol") + .and_then(|v| v.as_str()) + .map(|s| s.eq_ignore_ascii_case(&ticker_lower)) + .unwrap_or(false) + }) + .or_else(|| coins.first()) + .ok_or_else(|| ToolError::execution_failed("CoinGecko returned no results"))?; + + let id = selected + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::execution_failed("Missing CoinGecko id"))?; + let symbol = selected + .get("symbol") + .and_then(|v| v.as_str()) + .unwrap_or(&req.ticker) + .to_string(); + let name = selected + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(&req.ticker) + .to_string(); + + let price_url = format!( + "https://api.coingecko.com/api/v3/simple/price?ids={id}&vs_currencies=usd&include_last_updated_at=true" + ); + let price_resp = client + .get(&price_url) + .send() + .await + .map_err(|e| ToolError::execution_failed(format!("CoinGecko price failed: {e}")))?; + let status = price_resp.status(); + let body = price_resp + .text() + .await + .map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?; + if !status.is_success() { + return Err(ToolError::execution_failed(format!( + "CoinGecko price failed: HTTP {}", + status.as_u16() + ))); + } + let json: Value = serde_json::from_str(&body) + .map_err(|e| ToolError::execution_failed(format!("Invalid CoinGecko price JSON: {e}")))?; + let price = json + .get(id) + .and_then(|v| v.get("usd")) + .and_then(|v| v.as_f64()); + let last_updated = json + .get(id) + .and_then(|v| v.get("last_updated_at")) + .and_then(|v| v.as_i64()) + .map(|ts| format!("{ts}")); + + Ok(FinanceResult { + ticker: req.ticker.clone(), + instrument_type: req.instrument_type.clone(), + market: req.market.clone(), + source: "coingecko".to_string(), + price, + currency: Some("USD".to_string()), + as_of: last_updated, + details: json!({ + "id": id, + "symbol": symbol, + "name": name, + }), + }) +} + +async fn fetch_stooq_price( + client: &reqwest::Client, + req: &FinanceRequest, +) -> Result { + let symbol = normalize_stooq_symbol(&req.ticker, &req.market); + let url = format!( + "https://stooq.com/q/l/?s={symbol}&f=sd2t2ohlcv&h&e=csv" + ); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| ToolError::execution_failed(format!("Stooq request failed: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?; + if !status.is_success() { + return Err(ToolError::execution_failed(format!( + "Stooq failed: HTTP {}", + status.as_u16() + ))); + } + + let mut lines = body.lines(); + let _header = lines.next(); + let data = lines + .next() + .ok_or_else(|| ToolError::execution_failed("Stooq returned no data"))?; + let fields: Vec<&str> = data.split(',').collect(); + if fields.len() < 8 { + return Err(ToolError::execution_failed("Stooq data malformed")); + } + if fields[1] == "N/D" { + return Err(ToolError::execution_failed("Stooq returned no data")); + } + + let date = fields[1].to_string(); + let time = fields[2].to_string(); + let open = parse_f64(fields[3]); + let high = parse_f64(fields[4]); + let low = parse_f64(fields[5]); + let close = parse_f64(fields[6]); + let volume = parse_f64(fields[7]); + + Ok(FinanceResult { + ticker: req.ticker.clone(), + instrument_type: req.instrument_type.clone(), + market: req.market.clone(), + source: "stooq".to_string(), + price: close, + currency: None, + as_of: Some(format!("{date} {time}")), + details: json!({ + "symbol": symbol, + "open": open, + "high": high, + "low": low, + "close": close, + "volume": volume, + "date": date, + "time": time, + }), + }) +} + +fn normalize_stooq_symbol(ticker: &str, market: &str) -> String { + if ticker.contains('.') { + return ticker.to_lowercase(); + } + let suffix = match market.to_lowercase().as_str() { + "usa" | "us" => ".us", + "uk" | "gb" => ".uk", + "jp" | "japan" => ".jp", + "de" | "germany" => ".de", + "fr" | "france" => ".fr", + "ca" | "canada" => ".ca", + _ => "", + }; + format!("{}{}", ticker.to_lowercase(), suffix) +} + +fn parse_f64(input: &str) -> Option { + input.parse::().ok() +} + +fn url_encode(input: &str) -> String { + let mut encoded = String::new(); + for ch in input.bytes() { + match ch { + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'_' + | b'.' + | b'~' => encoded.push(ch as char), + b' ' => encoded.push('+'), + _ => encoded.push_str(&format!("%{ch:02X}")), + } + } + encoded +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index f4879e71..87c129e7 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -7,10 +7,15 @@ pub mod apply_patch; pub mod diagnostics; pub mod duo; +pub mod calculator; +pub mod finance; pub mod file; pub mod file_search; pub mod git; +pub mod sports; +pub mod time; pub mod plan; +pub mod parallel; pub mod project; pub mod registry; pub mod review; @@ -22,7 +27,10 @@ pub mod subagent; pub mod swarm; pub mod test_runner; pub mod todo; +pub mod user_input; pub mod web_search; +pub mod web_run; +pub mod weather; // === Re-exports === @@ -36,8 +44,16 @@ pub use registry::{ToolRegistry, ToolRegistryBuilder}; pub use file_search::FileSearchTool; pub use search::GrepFilesTool; +// Re-export structured data tools +pub use calculator::CalculatorTool; +pub use finance::FinanceTool; +pub use sports::SportsTool; +pub use time::TimeTool; +pub use weather::WeatherTool; + // Re-export web search tools pub use web_search::WebSearchTool; +pub use web_run::WebRunTool; // Re-export patch tools pub use apply_patch::ApplyPatchTool; @@ -55,7 +71,7 @@ pub use diagnostics::DiagnosticsTool; pub use git::{GitDiffTool, GitStatusTool}; // Re-export shell types -pub use shell::ExecShellTool; +pub use shell::{ExecShellTool, ShellInteractTool, ShellWaitTool}; // Re-export subagent types pub use subagent::SubAgent; @@ -69,5 +85,11 @@ pub use todo::TodoWriteTool; // Re-export plan types pub use plan::UpdatePlanTool; +// Re-export parallel/multi-tool types +pub use parallel::MultiToolUseParallelTool; + +// Re-export user input tool/types +pub use user_input::{RequestUserInputTool, UserInputAnswer, UserInputRequest, UserInputResponse}; + // Re-export RLM tools pub use rlm::{RlmExecTool, RlmLoadTool, RlmQueryTool, RlmStatusTool}; diff --git a/src/tools/parallel.rs b/src/tools/parallel.rs new file mode 100644 index 00000000..8d286804 --- /dev/null +++ b/src/tools/parallel.rs @@ -0,0 +1,54 @@ +//! Tool wrapper for executing multiple tool calls in parallel. + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, +}; +use async_trait::async_trait; +use serde_json::{Value, json}; + +pub struct MultiToolUseParallelTool; + +#[async_trait] +impl ToolSpec for MultiToolUseParallelTool { + fn name(&self) -> &'static str { + "multi_tool_use.parallel" + } + + fn description(&self) -> &'static str { + "Execute multiple tool calls in parallel and return their results." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "tool_uses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "recipient_name": { "type": "string" }, + "parameters": { "type": "object" } + }, + "required": ["recipient_name", "parameters"] + } + } + }, + "required": ["tool_uses"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, _input: Value, _context: &ToolContext) -> Result { + Err(ToolError::execution_failed( + "multi_tool_use.parallel must be handled by the engine", + )) + } +} diff --git a/src/tools/registry.rs b/src/tools/registry.rs index c8953b2b..e6039318 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -280,8 +280,12 @@ impl ToolRegistryBuilder { /// Include shell execution tool. #[must_use] pub fn with_shell_tools(self) -> Self { - use super::shell::ExecShellTool; + use super::shell::{ExecShellTool, ShellInteractTool, ShellWaitTool}; self.with_tool(Arc::new(ExecShellTool)) + .with_tool(Arc::new(ShellWaitTool::new("exec_shell_wait"))) + .with_tool(Arc::new(ShellInteractTool::new("exec_shell_interact"))) + .with_tool(Arc::new(ShellWaitTool::new("exec_wait"))) + .with_tool(Arc::new(ShellInteractTool::new("exec_interact"))) } /// Include search tools (`grep_files`). @@ -325,8 +329,35 @@ impl ToolRegistryBuilder { /// Include web search tools. #[must_use] pub fn with_web_tools(self) -> Self { + use super::web_run::WebRunTool; use super::web_search::WebSearchTool; self.with_tool(Arc::new(WebSearchTool)) + .with_tool(Arc::new(WebRunTool)) + } + + /// Include multi-tool parallel wrapper. + #[must_use] + pub fn with_parallel_tool(self) -> Self { + use super::parallel::MultiToolUseParallelTool; + self.with_tool(Arc::new(MultiToolUseParallelTool)) + } + + /// Include request_user_input tool. + #[must_use] + pub fn with_user_input_tool(self) -> Self { + use super::user_input::RequestUserInputTool; + self.with_tool(Arc::new(RequestUserInputTool)) + } + + /// Include structured data tools (weather/finance/sports/time/calculator). + #[must_use] + pub fn with_structured_data_tools(self) -> Self { + use super::{CalculatorTool, FinanceTool, SportsTool, TimeTool, WeatherTool}; + self.with_tool(Arc::new(WeatherTool)) + .with_tool(Arc::new(FinanceTool)) + .with_tool(Arc::new(SportsTool)) + .with_tool(Arc::new(TimeTool)) + .with_tool(Arc::new(CalculatorTool)) } /// Include patch tools (`apply_patch`). @@ -364,6 +395,9 @@ impl ToolRegistryBuilder { .with_note_tool() .with_search_tools() .with_web_tools() + .with_user_input_tool() + .with_parallel_tool() + .with_structured_data_tools() .with_patch_tools() .with_git_tools() .with_diagnostics_tool() @@ -449,7 +483,8 @@ impl ToolRegistryBuilder { runtime: super::subagent::SubAgentRuntime, ) -> Self { use super::subagent::{ - AgentCancelTool, AgentListTool, AgentResultTool, AgentSpawnTool, DelegateToAgentTool, + AgentCancelTool, AgentListTool, AgentResultTool, AgentSendInputTool, AgentSpawnTool, + AgentWaitTool, DelegateToAgentTool, }; use super::swarm::AgentSwarmTool; @@ -463,6 +498,10 @@ impl ToolRegistryBuilder { ))) .with_tool(Arc::new(AgentSwarmTool::new(manager.clone(), runtime))) .with_tool(Arc::new(AgentResultTool::new(manager.clone()))) + .with_tool(Arc::new(AgentSendInputTool::new(manager.clone(), "send_input"))) + .with_tool(Arc::new(AgentWaitTool::new(manager.clone(), "wait"))) + .with_tool(Arc::new(AgentSendInputTool::new(manager.clone(), "agent_send_input"))) + .with_tool(Arc::new(AgentWaitTool::new(manager.clone(), "agent_wait"))) .with_tool(Arc::new(AgentCancelTool::new(manager.clone()))) .with_tool(Arc::new(AgentListTool::new(manager))) } diff --git a/src/tools/shell.rs b/src/tools/shell.rs index c528387c..61d8f0c3 100644 --- a/src/tools/shell.rs +++ b/src/tools/shell.rs @@ -13,12 +13,14 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::io::{Read, Write}; use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use uuid::Uuid; use wait_timeout::ChildExt; +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + use crate::sandbox::{ CommandSpec, ExecEnv, @@ -81,6 +83,104 @@ pub struct ShellResult { pub sandbox_denied: bool, } +struct ShellDeltaResult { + result: ShellResult, + stdout_total_len: usize, + stderr_total_len: usize, +} + +enum ShellChild { + Process(Child), + Pty(Box), +} + +#[derive(Clone, Copy, Debug)] +struct ShellExitStatus { + code: Option, + success: bool, +} + +impl ShellExitStatus { + fn from_std(status: std::process::ExitStatus) -> Self { + Self { + code: status.code(), + success: status.success(), + } + } + + fn from_pty(status: portable_pty::ExitStatus) -> Self { + let code = i32::try_from(status.exit_code()).unwrap_or(i32::MAX); + Self { + code: Some(code), + success: status.success(), + } + } +} + +impl ShellChild { + fn try_wait(&mut self) -> std::io::Result> { + match self { + ShellChild::Process(child) => child.try_wait().map(|status| status.map(ShellExitStatus::from_std)), + ShellChild::Pty(child) => child.try_wait().map(|status| status.map(ShellExitStatus::from_pty)), + } + } + + fn wait(&mut self) -> std::io::Result { + match self { + ShellChild::Process(child) => child.wait().map(ShellExitStatus::from_std), + ShellChild::Pty(child) => child.wait().map(ShellExitStatus::from_pty), + } + } + + fn kill(&mut self) -> std::io::Result<()> { + match self { + ShellChild::Process(child) => child.kill(), + ShellChild::Pty(child) => child.kill(), + } + } +} + +enum StdinWriter { + Pipe(ChildStdin), + Pty(Box), +} + +impl StdinWriter { + fn write_all(&mut self, data: &[u8]) -> std::io::Result<()> { + match self { + StdinWriter::Pipe(stdin) => stdin.write_all(data), + StdinWriter::Pty(writer) => writer.write_all(data), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + StdinWriter::Pipe(stdin) => stdin.flush(), + StdinWriter::Pty(writer) => writer.flush(), + } + } +} + +fn spawn_reader_thread( + mut reader: R, + buffer: Arc>>, +) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let mut chunk = [0u8; 4096]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + if let Ok(mut guard) = buffer.lock() { + guard.extend_from_slice(&chunk[..n]); + } + } + Err(_) => break, + } + } + }) +} + /// A background shell process being tracked pub struct BackgroundShell { pub id: String, @@ -88,13 +188,16 @@ pub struct BackgroundShell { pub working_dir: PathBuf, pub status: ShellStatus, pub exit_code: Option, - pub stdout: String, - pub stderr: String, pub started_at: Instant, pub sandbox_type: SandboxType, - child: Option, - stdout_thread: Option>>, - stderr_thread: Option>>, + stdout_buffer: Arc>>, + stderr_buffer: Option>>>, + stdout_cursor: usize, + stderr_cursor: usize, + stdin: Option, + child: Option, + stdout_thread: Option>, + stderr_thread: Option>, } impl BackgroundShell { @@ -107,8 +210,8 @@ impl BackgroundShell { if let Some(ref mut child) = self.child { match child.try_wait() { Ok(Some(status)) => { - self.exit_code = status.code(); - self.status = if status.success() { + self.exit_code = status.code; + self.status = if status.success { ShellStatus::Completed } else { ShellStatus::Failed @@ -119,6 +222,7 @@ impl BackgroundShell { Ok(None) => false, // Still running Err(_) => { self.status = ShellStatus::Failed; + self.collect_output(); true } } @@ -129,34 +233,111 @@ impl BackgroundShell { /// Collect output from the background threads fn collect_output(&mut self) { - if let Some(handle) = self.stdout_thread.take() - && let Ok(data) = handle.join() - { - self.stdout = String::from_utf8_lossy(&data).to_string(); + if let Some(handle) = self.stdout_thread.take() { + let _ = handle.join(); } - if let Some(handle) = self.stderr_thread.take() - && let Ok(data) = handle.join() - { - self.stderr = String::from_utf8_lossy(&data).to_string(); + if let Some(handle) = self.stderr_thread.take() { + let _ = handle.join(); } } + fn write_stdin(&mut self, input: &str, close: bool) -> Result<()> { + if let Some(stdin) = self.stdin.as_mut() { + if !input.is_empty() { + stdin + .write_all(input.as_bytes()) + .context("Failed to write to stdin")?; + stdin.flush().ok(); + } + if close { + self.stdin = None; + } + return Ok(()); + } + + if input.is_empty() && close { + return Ok(()); + } + + Err(anyhow!("stdin is not available for task {}", self.id)) + } + + fn full_output(&self) -> (String, String, usize, usize) { + let stdout_bytes = self + .stdout_buffer + .lock() + .map(|data| data.clone()) + .unwrap_or_default(); + let stderr_bytes = self + .stderr_buffer + .as_ref() + .and_then(|buffer| buffer.lock().ok().map(|data| data.clone())) + .unwrap_or_default(); + + let stdout_len = stdout_bytes.len(); + let stderr_len = stderr_bytes.len(); + + ( + String::from_utf8_lossy(&stdout_bytes).to_string(), + String::from_utf8_lossy(&stderr_bytes).to_string(), + stdout_len, + stderr_len, + ) + } + + fn take_delta(&mut self) -> (String, String, usize, usize, usize, usize) { + let (stdout_delta, stdout_total) = take_delta_from_buffer( + &self.stdout_buffer, + &mut self.stdout_cursor, + ); + let (stderr_delta, stderr_total) = if let Some(buffer) = self.stderr_buffer.as_ref() { + take_delta_from_buffer(buffer, &mut self.stderr_cursor) + } else { + (Vec::new(), 0) + }; + + let stdout_delta_len = stdout_delta.len(); + let stderr_delta_len = stderr_delta.len(); + + ( + String::from_utf8_lossy(&stdout_delta).to_string(), + String::from_utf8_lossy(&stderr_delta).to_string(), + stdout_delta_len, + stderr_delta_len, + stdout_total, + stderr_total, + ) + } + + fn sandbox_denied(&self) -> bool { + if matches!(self.status, ShellStatus::Running) { + return false; + } + let (_, stderr_full, _, _) = self.full_output(); + SandboxManager::was_denied( + self.sandbox_type, + self.exit_code.unwrap_or(-1), + &stderr_full, + ) + } + /// Kill the process fn kill(&mut self) -> Result<()> { if let Some(ref mut child) = self.child { child.kill().context("Failed to kill process")?; - let _ = child.wait(); // Reap the zombie - self.status = ShellStatus::Killed; - self.collect_output(); + let _ = child.wait(); } + self.status = ShellStatus::Killed; + self.collect_output(); Ok(()) } /// Get a snapshot of the current state pub fn snapshot(&self) -> ShellResult { let sandboxed = !matches!(self.sandbox_type, SandboxType::None); - let (stdout, stdout_meta) = truncate_with_meta(&self.stdout); - let (stderr, stderr_meta) = truncate_with_meta(&self.stderr); + let (stdout_full, stderr_full, _, _) = self.full_output(); + let (stdout, stdout_meta) = truncate_with_meta(&stdout_full); + let (stderr, stderr_meta) = truncate_with_meta(&stderr_full); ShellResult { task_id: Some(self.id.clone()), status: self.status.clone(), @@ -176,7 +357,7 @@ impl BackgroundShell { } else { None }, - sandbox_denied: false, // Determined after completion + sandbox_denied: self.sandbox_denied(), } } } @@ -244,6 +425,28 @@ impl ShellManager { timeout_ms: u64, background: bool, policy_override: Option, + ) -> Result { + self.execute_with_options( + command, + working_dir, + timeout_ms, + background, + None, + false, + policy_override, + ) + } + + /// Execute a shell command with stdin/TTY options. + pub fn execute_with_options( + &mut self, + command: &str, + working_dir: Option<&str>, + timeout_ms: u64, + background: bool, + stdin_data: Option<&str>, + tty: bool, + policy_override: Option, ) -> Result { let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from); @@ -259,9 +462,14 @@ impl ShellManager { let exec_env = self.sandbox_manager.prepare(&spec); if background { - self.spawn_background_sandboxed(command, &work_dir, &exec_env) + self.spawn_background_sandboxed(command, &work_dir, &exec_env, stdin_data, tty) } else { - Self::execute_sync_sandboxed(command, &work_dir, timeout_ms, &exec_env) + if tty { + return Err(anyhow!( + "TTY mode requires background execution (set background: true)." + )); + } + Self::execute_sync_sandboxed(command, &work_dir, timeout_ms, stdin_data, &exec_env) } } @@ -300,6 +508,7 @@ impl ShellManager { original_command: &str, working_dir: &std::path::Path, timeout_ms: u64, + stdin_data: Option<&str>, exec_env: &ExecEnv, ) -> Result { let started = Instant::now(); @@ -317,6 +526,10 @@ impl ShellManager { .stdout(Stdio::piped()) .stderr(Stdio::piped()); + if stdin_data.is_some() { + cmd.stdin(Stdio::piped()); + } + // Set environment variables from exec_env for (key, value) in &exec_env.env { cmd.env(key, value); @@ -326,6 +539,15 @@ impl ShellManager { .spawn() .with_context(|| format!("Failed to execute: {original_command}"))?; + if let Some(input) = stdin_data { + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(input.as_bytes()) + .context("Failed to write to stdin")?; + stdin.flush().ok(); + } + } + let stdout_handle = child.stdout.take().context("Failed to capture stdout")?; let stderr_handle = child.stderr.take().context("Failed to capture stderr")?; @@ -507,6 +729,8 @@ impl ShellManager { original_command: &str, working_dir: &std::path::Path, exec_env: &ExecEnv, + stdin_data: Option<&str>, + tty: bool, ) -> Result { let task_id = format!("shell_{}", &Uuid::new_v4().to_string()[..8]); let started = Instant::now(); @@ -517,58 +741,110 @@ impl ShellManager { let program = exec_env.program(); let args = exec_env.args(); - let mut cmd = Command::new(program); - cmd.args(args) - .current_dir(working_dir) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + let stdout_buffer = Arc::new(Mutex::new(Vec::new())); + let stderr_buffer = if tty { + None + } else { + Some(Arc::new(Mutex::new(Vec::new()))) + }; - // Set environment variables from exec_env - for (key, value) in &exec_env.env { - cmd.env(key, value); - } + let (child, stdin, stdout_thread, stderr_thread) = if tty { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .context("Failed to open PTY")?; - let mut child = cmd - .spawn() - .with_context(|| format!("Failed to spawn background: {original_command}"))?; + let mut cmd = CommandBuilder::new(program); + for arg in args { + cmd.arg(arg); + } + cmd.cwd(working_dir); + for (key, value) in &exec_env.env { + cmd.env(key, value); + } - let stdout_handle = child.stdout.take(); - let stderr_handle = child.stderr.take(); + let child = pair + .slave + .spawn_command(cmd) + .with_context(|| format!("Failed to spawn PTY command: {original_command}"))?; + drop(pair.slave); - // Spawn threads to collect output - let stdout_thread = stdout_handle.map(|handle| { - std::thread::spawn(move || { - let mut reader = handle; - let mut buf = Vec::new(); - let _ = reader.read_to_end(&mut buf); - buf - }) - }); + let reader = pair + .master + .try_clone_reader() + .context("Failed to clone PTY reader")?; + let stdout_thread = Some(spawn_reader_thread(reader, Arc::clone(&stdout_buffer))); + let writer = pair + .master + .take_writer() + .context("Failed to take PTY writer")?; - let stderr_thread = stderr_handle.map(|handle| { - std::thread::spawn(move || { - let mut reader = handle; - let mut buf = Vec::new(); - let _ = reader.read_to_end(&mut buf); - buf - }) - }); + ( + ShellChild::Pty(child), + Some(StdinWriter::Pty(writer)), + stdout_thread, + None, + ) + } else { + let mut cmd = Command::new(program); + cmd.args(args) + .current_dir(working_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); - let bg_shell = BackgroundShell { + for (key, value) in &exec_env.env { + cmd.env(key, value); + } + + let mut child = cmd + .spawn() + .with_context(|| format!("Failed to spawn background: {original_command}"))?; + + let stdout_handle = child.stdout.take().context("Failed to capture stdout")?; + let stderr_handle = child.stderr.take().context("Failed to capture stderr")?; + let stdin_handle = child.stdin.take().map(StdinWriter::Pipe); + + let stdout_thread = Some(spawn_reader_thread(stdout_handle, Arc::clone(&stdout_buffer))); + let stderr_thread = stderr_buffer + .as_ref() + .map(|buffer| spawn_reader_thread(stderr_handle, Arc::clone(buffer))); + + ( + ShellChild::Process(child), + stdin_handle, + stdout_thread, + stderr_thread, + ) + }; + + let mut bg_shell = BackgroundShell { id: task_id.clone(), command: original_command.to_string(), working_dir: working_dir.to_path_buf(), status: ShellStatus::Running, exit_code: None, - stdout: String::new(), - stderr: String::new(), started_at: started, sandbox_type, + stdout_buffer, + stderr_buffer, + stdout_cursor: 0, + stderr_cursor: 0, + stdin, child: Some(child), stdout_thread, stderr_thread, }; + if let Some(input) = stdin_data { + bg_shell.write_stdin(input, false)?; + } + self.processes.insert(task_id.clone(), bg_shell); Ok(ShellResult { @@ -628,6 +904,77 @@ impl ShellManager { Ok(shell.snapshot()) } + /// Write data to stdin of a background process. + pub fn write_stdin(&mut self, task_id: &str, input: &str, close: bool) -> Result<()> { + let shell = self + .processes + .get_mut(task_id) + .ok_or_else(|| anyhow!("Task {task_id} not found"))?; + shell.write_stdin(input, close)?; + Ok(()) + } + + /// Get incremental output from a background process, consuming any new output. + fn get_output_delta( + &mut self, + task_id: &str, + wait: bool, + timeout_ms: u64, + ) -> Result { + let shell = self + .processes + .get_mut(task_id) + .ok_or_else(|| anyhow!("Task {task_id} not found"))?; + + if wait && shell.status == ShellStatus::Running { + let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000)); + let deadline = Instant::now() + timeout; + + while shell.status == ShellStatus::Running && Instant::now() < deadline { + if shell.poll() { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + } else { + shell.poll(); + } + + let (stdout_delta, stderr_delta, stdout_delta_len, stderr_delta_len, stdout_total, stderr_total) = + shell.take_delta(); + let (stdout, stdout_meta) = truncate_with_meta(&stdout_delta); + let (stderr, stderr_meta) = truncate_with_meta(&stderr_delta); + let sandboxed = !matches!(shell.sandbox_type, SandboxType::None); + + let result = ShellResult { + task_id: Some(shell.id.clone()), + status: shell.status.clone(), + exit_code: shell.exit_code, + stdout, + stderr, + duration_ms: u64::try_from(shell.started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + stdout_len: stdout_meta.original_len.max(stdout_delta_len), + stderr_len: stderr_meta.original_len.max(stderr_delta_len), + stdout_omitted: stdout_meta.omitted, + stderr_omitted: stderr_meta.omitted, + stdout_truncated: stdout_meta.truncated, + stderr_truncated: stderr_meta.truncated, + sandboxed, + sandbox_type: if sandboxed { + Some(shell.sandbox_type.to_string()) + } else { + None + }, + sandbox_denied: shell.sandbox_denied(), + }; + + Ok(ShellDeltaResult { + result, + stdout_total_len: stdout_total, + stderr_total_len: stderr_total, + }) + } + /// Kill a running background process pub fn kill(&mut self, task_id: &str) -> Result { let shell = self @@ -718,6 +1065,17 @@ fn char_boundary_at_or_before(text: &str, max_bytes: usize) -> usize { last_end.min(text.len()) } +fn take_delta_from_buffer( + buffer: &Arc>>, + cursor: &mut usize, +) -> (Vec, usize) { + let data = buffer.lock().map(|d| d.clone()).unwrap_or_default(); + let start = (*cursor).min(data.len()); + let delta = data[start..].to_vec(); + *cursor = data.len(); + (delta, data.len()) +} + fn strip_truncation_note(text: &str) -> &str { text.split_once("\n\n[Output truncated at") .map_or(text, |(prefix, _)| prefix) @@ -820,6 +1178,14 @@ impl ToolSpec for ExecShellTool { "interactive": { "type": "boolean", "description": "Run interactively with terminal IO (default: false)" + }, + "stdin": { + "type": "string", + "description": "Optional stdin data to send before waiting (non-interactive only)" + }, + "tty": { + "type": "boolean", + "description": "Allocate a pseudo-terminal for interactive programs (implies background)" } }, "required": ["command"] @@ -847,12 +1213,31 @@ impl ToolSpec for ExecShellTool { let timeout_ms = optional_u64(&input, "timeout_ms", 120_000).min(600_000); let background = optional_bool(&input, "background", false); let interactive = optional_bool(&input, "interactive", false); + let tty = optional_bool(&input, "tty", false); + let stdin_data = input + .get("stdin") + .or_else(|| input.get("input")) + .or_else(|| input.get("data")) + .and_then(serde_json::Value::as_str) + .map(str::to_string); if interactive && background { return Ok(ToolResult::error( "Interactive commands cannot run in background mode.", )); } + if interactive && tty { + return Ok(ToolResult::error( + "Interactive mode cannot be combined with TTY sessions.", + )); + } + if interactive && stdin_data.is_some() { + return Ok(ToolResult::error( + "Interactive mode cannot be combined with stdin data.", + )); + } + + let background = background || tty; let mut execpolicy_decision: Option = None; if let Some(policy) = load_default_policy() @@ -904,21 +1289,24 @@ impl ToolSpec for ExecShellTool { } } - // Create a shell manager for this execution - // If there's an elevated sandbox policy, use it; otherwise use default - let mut manager = if let Some(ref policy) = context.elevated_sandbox_policy { - ShellManager::with_sandbox(context.workspace.clone(), policy.clone()) - } else { - ShellManager::new(context.workspace.clone()) - }; - - // Pass the elevated policy as override if set let policy_override = context.elevated_sandbox_policy.clone(); + let mut manager = context + .shell_manager + .lock() + .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?; let result = if interactive { manager.execute_interactive(command, None, timeout_ms) } else { - manager.execute_with_policy(command, None, timeout_ms, background, policy_override) + manager.execute_with_options( + command, + None, + timeout_ms, + background, + stdin_data.as_deref(), + tty, + policy_override, + ) }; match result { @@ -997,6 +1385,255 @@ impl ToolSpec for ExecShellTool { } } +pub struct ShellWaitTool { + name: &'static str, +} + +impl ShellWaitTool { + pub const fn new(name: &'static str) -> Self { + Self { name } + } +} + +pub struct ShellInteractTool { + name: &'static str, +} + +impl ShellInteractTool { + pub const fn new(name: &'static str) -> Self { + Self { name } + } +} + +fn required_task_id<'a>(input: &'a serde_json::Value) -> Result<&'a str, ToolError> { + input + .get("task_id") + .or_else(|| input.get("id")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| ToolError::missing_field("task_id")) +} + +fn build_shell_delta_tool_result(delta: ShellDeltaResult) -> ToolResult { + let result = delta.result; + let stdout_summary = summarize_output(&result.stdout); + let stderr_summary = summarize_output(&result.stderr); + let summary = if !stderr_summary.is_empty() { + stderr_summary.clone() + } else { + stdout_summary.clone() + }; + + let output = if result.stdout.is_empty() && result.stderr.is_empty() { + match result.status { + ShellStatus::Running => "Background task running (no new output).".to_string(), + ShellStatus::Completed => "(no new output)".to_string(), + ShellStatus::Failed => format!( + "Command failed (exit code: {:?})", + result.exit_code + ), + ShellStatus::TimedOut => "Command timed out (no new output).".to_string(), + ShellStatus::Killed => "Command killed (no new output).".to_string(), + } + } else if result.stderr.is_empty() { + result.stdout.clone() + } else { + format!("{}\n\nSTDERR:\n{}", result.stdout, result.stderr) + }; + + ToolResult { + content: output, + success: matches!( + result.status, + ShellStatus::Completed | ShellStatus::Running + ), + metadata: Some(json!({ + "exit_code": result.exit_code, + "status": format!("{:?}", result.status), + "duration_ms": result.duration_ms, + "sandboxed": result.sandboxed, + "sandbox_type": result.sandbox_type, + "sandbox_denied": result.sandbox_denied, + "task_id": result.task_id, + "stdout_len": result.stdout_len, + "stderr_len": result.stderr_len, + "stdout_truncated": result.stdout_truncated, + "stderr_truncated": result.stderr_truncated, + "stdout_omitted": result.stdout_omitted, + "stderr_omitted": result.stderr_omitted, + "stdout_total_len": delta.stdout_total_len, + "stderr_total_len": delta.stderr_total_len, + "summary": summary, + "stdout_summary": stdout_summary, + "stderr_summary": stderr_summary, + "stream_delta": true, + })), + } +} + +#[async_trait] +impl ToolSpec for ShellWaitTool { + fn name(&self) -> &'static str { + self.name + } + + fn description(&self) -> &'static str { + "Wait for a background shell task and return incremental output." + } + + fn input_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task ID returned by exec_shell" + }, + "timeout_ms": { + "type": "integer", + "description": "Timeout in milliseconds (default: 5000)" + }, + "wait": { + "type": "boolean", + "description": "Wait for completion before returning (default: true)" + } + }, + "required": ["task_id"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute( + &self, + input: serde_json::Value, + context: &ToolContext, + ) -> Result { + let task_id = required_task_id(&input)?; + let wait = optional_bool(&input, "wait", true); + let timeout_ms = optional_u64(&input, "timeout_ms", 5_000); + + let mut manager = context + .shell_manager + .lock() + .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?; + let delta = manager + .get_output_delta(task_id, wait, timeout_ms) + .map_err(|err| ToolError::execution_failed(err.to_string()))?; + + Ok(build_shell_delta_tool_result(delta)) + } +} + +#[async_trait] +impl ToolSpec for ShellInteractTool { + fn name(&self) -> &'static str { + self.name + } + + fn description(&self) -> &'static str { + "Send input to a background shell task and return incremental output." + } + + fn input_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task ID returned by exec_shell" + }, + "input": { + "type": "string", + "description": "Input to send to the task's stdin" + }, + "stdin": { + "type": "string", + "description": "Alias for input" + }, + "data": { + "type": "string", + "description": "Alias for input" + }, + "timeout_ms": { + "type": "integer", + "description": "Wait for output after sending input (default: 1000)" + }, + "close_stdin": { + "type": "boolean", + "description": "Close stdin after sending input" + } + }, + "required": ["task_id"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ExecutesCode] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute( + &self, + input: serde_json::Value, + context: &ToolContext, + ) -> Result { + let task_id = required_task_id(&input)?; + let close_stdin = optional_bool(&input, "close_stdin", false); + let timeout_ms = optional_u64(&input, "timeout_ms", 1_000); + let interaction_input = input + .get("input") + .or_else(|| input.get("stdin")) + .or_else(|| input.get("data")) + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + + { + let mut manager = context + .shell_manager + .lock() + .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?; + if !interaction_input.is_empty() || close_stdin { + manager + .write_stdin(task_id, interaction_input, close_stdin) + .map_err(|err| ToolError::execution_failed(err.to_string()))?; + } + } + + let mut elapsed = 0u64; + loop { + let delta = { + let mut manager = context + .shell_manager + .lock() + .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?; + manager + .get_output_delta(task_id, false, 0) + .map_err(|err| ToolError::execution_failed(err.to_string()))? + }; + + if !delta.result.stdout.is_empty() + || !delta.result.stderr.is_empty() + || delta.result.status != ShellStatus::Running + || elapsed >= timeout_ms + { + return Ok(build_shell_delta_tool_result(delta)); + } + + std::thread::sleep(Duration::from_millis(50)); + elapsed = elapsed.saturating_add(50); + } + } +} + /// Tool for appending notes to a notes file. pub struct NoteTool; @@ -1103,6 +1740,17 @@ mod tests { } } + fn echo_stdin_command() -> String { + #[cfg(windows)] + { + "more".to_string() + } + #[cfg(not(windows))] + { + "cat".to_string() + } + } + #[test] fn test_sync_execution() { let tmp = tempdir().expect("tempdir"); @@ -1172,6 +1820,35 @@ mod tests { assert_eq!(killed.status, ShellStatus::Killed); } + #[test] + fn test_write_stdin_streams_output() { + let tmp = tempdir().expect("tempdir"); + let mut manager = ShellManager::new(tmp.path().to_path_buf()); + + let result = manager + .execute_with_options(&echo_stdin_command(), None, 5000, true, None, false, None) + .expect("execute"); + + let task_id = result + .task_id + .expect("background execution should return task_id"); + + manager + .write_stdin(&task_id, "hello\n", true) + .expect("write stdin"); + + let delta = manager + .get_output_delta(&task_id, true, 5000) + .expect("get_output_delta"); + + assert!(delta.result.stdout.contains("hello")); + + let delta2 = manager + .get_output_delta(&task_id, false, 0) + .expect("get_output_delta"); + assert!(delta2.result.stdout.is_empty()); + } + #[test] fn test_output_truncation() { let long_output = "x".repeat(50_000); diff --git a/src/tools/spec.rs b/src/tools/spec.rs index 607838af..9a076667 100644 --- a/src/tools/spec.rs +++ b/src/tools/spec.rs @@ -13,6 +13,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; +use crate::tools::shell::{new_shared_shell_manager, SharedShellManager}; + /// Capabilities that a tool may have or require. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ToolCapability { @@ -177,6 +179,8 @@ pub enum SandboxPolicy { pub struct ToolContext { /// The workspace root directory pub workspace: PathBuf, + /// Shared shell manager for background tasks and streaming IO. + pub shell_manager: SharedShellManager, /// Whether to allow paths outside workspace pub trust_mode: bool, /// Current sandbox policy @@ -198,10 +202,12 @@ impl ToolContext { #[must_use] pub fn new(workspace: impl Into) -> Self { let workspace = workspace.into(); + let shell_manager = new_shared_shell_manager(workspace.clone()); let notes_path = workspace.join(".deepseek").join("notes.md"); let mcp_config_path = workspace.join(".deepseek").join("mcp.json"); Self { workspace, + shell_manager, trust_mode: false, sandbox_policy: SandboxPolicy::None, notes_path, @@ -218,8 +224,11 @@ impl ToolContext { notes_path: impl Into, mcp_config_path: impl Into, ) -> Self { + let workspace = workspace.into(); + let shell_manager = new_shared_shell_manager(workspace.clone()); Self { - workspace: workspace.into(), + workspace, + shell_manager, trust_mode, sandbox_policy: SandboxPolicy::None, notes_path: notes_path.into(), @@ -237,8 +246,11 @@ impl ToolContext { mcp_config_path: impl Into, auto_approve: bool, ) -> Self { + let workspace = workspace.into(); + let shell_manager = new_shared_shell_manager(workspace.clone()); Self { - workspace: workspace.into(), + workspace, + shell_manager, trust_mode, sandbox_policy: SandboxPolicy::None, notes_path: notes_path.into(), @@ -376,6 +388,12 @@ impl ToolContext { self } + /// Override the shared shell manager. + pub fn with_shell_manager(mut self, shell_manager: SharedShellManager) -> Self { + self.shell_manager = shell_manager; + self + } + /// Set the elevated sandbox policy override. /// /// This is used when retrying a tool after a sandbox denial, to run diff --git a/src/tools/sports.rs b/src/tools/sports.rs new file mode 100644 index 00000000..5d26e96d --- /dev/null +++ b/src/tools/sports.rs @@ -0,0 +1,418 @@ +//! Sports tool for schedules and standings (ESPN public APIs). + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64, + optional_str, required_str, +}; +use async_trait::async_trait; +use chrono::{NaiveDate, Utc}; +use serde::Serialize; +use serde_json::{Value, json}; +use std::time::Duration; + +const TIMEOUT_MS: u64 = 15_000; +const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; + +#[derive(Debug, Clone, Serialize)] +struct SportsGameTeam { + name: String, + abbreviation: String, + score: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct SportsGame { + id: String, + date: String, + status: String, + home: SportsGameTeam, + away: SportsGameTeam, +} + +#[derive(Debug, Clone, Serialize)] +struct SportsScheduleResponse { + league: String, + games: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct SportsStandingEntry { + name: String, + abbreviation: String, + stats: Value, +} + +#[derive(Debug, Clone, Serialize)] +struct SportsStandingsResponse { + league: String, + entries: Vec, +} + +pub struct SportsTool; + +#[async_trait] +impl ToolSpec for SportsTool { + fn name(&self) -> &'static str { + "sports" + } + + fn description(&self) -> &'static str { + "Get sports schedules or standings for a league." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "fn": { "type": "string", "enum": ["schedule", "standings"] }, + "league": { "type": "string" }, + "team": { "type": "string" }, + "opponent": { "type": "string" }, + "date_from": { "type": "string", "description": "YYYY-MM-DD" }, + "date_to": { "type": "string", "description": "YYYY-MM-DD" }, + "num_games": { "type": "integer" }, + "locale": { "type": "string" } + }, + "required": ["fn", "league"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly, ToolCapability::Network] + } + + fn supports_parallel(&self) -> bool { + true + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let action = required_str(&input, "fn")?.to_lowercase(); + let league = required_str(&input, "league")?.to_lowercase(); + let team = optional_str(&input, "team").map(|s| s.to_string()); + let opponent = optional_str(&input, "opponent").map(|s| s.to_string()); + let date_from = optional_str(&input, "date_from").map(|s| s.to_string()); + let date_to = optional_str(&input, "date_to").map(|s| s.to_string()); + let num_games = optional_u64(&input, "num_games", 20) as usize; + + let (sport, league_code) = map_league(&league)?; + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(TIMEOUT_MS)) + .user_agent(USER_AGENT) + .build() + .map_err(|e| ToolError::execution_failed(format!("Failed to build HTTP client: {e}")))?; + + match action.as_str() { + "schedule" => { + let schedule = fetch_schedule( + &client, + &sport, + &league_code, + date_from.as_deref(), + date_to.as_deref(), + team.as_deref(), + opponent.as_deref(), + num_games, + ) + .await?; + ToolResult::json(&schedule).map_err(|e| ToolError::execution_failed(e.to_string())) + } + "standings" => { + let standings = fetch_standings(&client, &sport, &league_code).await?; + ToolResult::json(&standings).map_err(|e| ToolError::execution_failed(e.to_string())) + } + _ => Err(ToolError::invalid_input("fn must be schedule or standings")), + } + } +} + +fn map_league(league: &str) -> Result<(String, String), ToolError> { + match league { + "nba" => Ok(("basketball".to_string(), "nba".to_string())), + "wnba" => Ok(("basketball".to_string(), "wnba".to_string())), + "nfl" => Ok(("football".to_string(), "nfl".to_string())), + "nhl" => Ok(("hockey".to_string(), "nhl".to_string())), + "mlb" => Ok(("baseball".to_string(), "mlb".to_string())), + "epl" => Ok(("soccer".to_string(), "eng.1".to_string())), + "ncaamb" => Ok(("basketball".to_string(), "mens-college-basketball".to_string())), + "ncaawb" => Ok(("basketball".to_string(), "womens-college-basketball".to_string())), + "ipl" => Ok(("cricket".to_string(), "ipl".to_string())), + _ => Err(ToolError::invalid_input("Unsupported league")), + } +} + +async fn fetch_schedule( + client: &reqwest::Client, + sport: &str, + league: &str, + date_from: Option<&str>, + date_to: Option<&str>, + team: Option<&str>, + opponent: Option<&str>, + num_games: usize, +) -> Result { + let date_param = build_date_param(date_from, date_to)?; + let url = if let Some(dates) = date_param { + format!( + "https://site.web.api.espn.com/apis/v2/sports/{sport}/{league}/scoreboard?dates={dates}" + ) + } else { + format!( + "https://site.web.api.espn.com/apis/v2/sports/{sport}/{league}/scoreboard" + ) + }; + + let resp = client + .get(&url) + .send() + .await + .map_err(|e| ToolError::execution_failed(format!("Schedule request failed: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?; + if !status.is_success() { + return Err(ToolError::execution_failed(format!( + "Schedule failed: HTTP {}", + status.as_u16() + ))); + } + + let json: Value = serde_json::from_str(&body) + .map_err(|e| ToolError::execution_failed(format!("Invalid schedule JSON: {e}")))?; + let events = json + .get("events") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let mut games = Vec::new(); + for event in events.iter() { + let competitions = match event.get("competitions").and_then(|v| v.as_array()) { + Some(list) => list, + None => continue, + }; + let competition = match competitions.first() { + Some(comp) => comp, + None => continue, + }; + let competitors = match competition.get("competitors").and_then(|v| v.as_array()) { + Some(list) => list, + None => continue, + }; + if competitors.len() < 2 { + continue; + } + let (home, away) = split_competitors(competitors); + if let (Some(home), Some(away)) = (home, away) { + if let Some(team_filter) = team { + if !team_matches(&home, team_filter) && !team_matches(&away, team_filter) { + continue; + } + } + if let Some(opponent_filter) = opponent { + if !team_matches(&home, opponent_filter) + && !team_matches(&away, opponent_filter) + { + continue; + } + } + let status = competition + .get("status") + .and_then(|v| v.get("type")) + .and_then(|v| v.get("description")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let game = SportsGame { + id: event + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + date: event + .get("date") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + status, + home, + away, + }; + games.push(game); + } + if games.len() >= num_games { + break; + } + } + + Ok(SportsScheduleResponse { + league: league.to_string(), + games, + }) +} + +fn split_competitors(competitors: &[Value]) -> (Option, Option) { + let mut home = None; + let mut away = None; + for comp in competitors { + let team = comp.get("team").and_then(|v| v.as_object()); + let name = team + .and_then(|t| t.get("displayName")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let abbreviation = team + .and_then(|t| t.get("abbreviation")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let score = comp + .get("score") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let home_away = comp + .get("homeAway") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let team_info = SportsGameTeam { + name, + abbreviation, + score, + }; + if home_away == "home" { + home = Some(team_info); + } else { + away = Some(team_info); + } + } + (home, away) +} + +fn team_matches(team: &SportsGameTeam, filter: &str) -> bool { + let req = filter.to_lowercase(); + team.abbreviation.to_lowercase() == req || team.name.to_lowercase().contains(&req) +} + +async fn fetch_standings( + client: &reqwest::Client, + sport: &str, + league: &str, +) -> Result { + let url = format!( + "https://site.web.api.espn.com/apis/v2/sports/{sport}/{league}/standings" + ); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| ToolError::execution_failed(format!("Standings request failed: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?; + if !status.is_success() { + return Err(ToolError::execution_failed(format!( + "Standings failed: HTTP {}", + status.as_u16() + ))); + } + let json: Value = serde_json::from_str(&body) + .map_err(|e| ToolError::execution_failed(format!("Invalid standings JSON: {e}")))?; + + let entries = collect_standings_entries(&json); + let mut output = Vec::new(); + for entry in entries { + let team = entry.get("team").and_then(|v| v.as_object()); + let name = team + .and_then(|t| t.get("displayName")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let abbreviation = team + .and_then(|t| t.get("abbreviation")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let stats = entry + .get("stats") + .and_then(|v| v.as_array()) + .map(|stats| { + let mut map = serde_json::Map::new(); + for stat in stats { + if let Some(name) = stat.get("name").and_then(|v| v.as_str()) { + if let Some(val) = stat.get("displayValue").or_else(|| stat.get("value")) { + map.insert(name.to_string(), val.clone()); + } + } + } + Value::Object(map) + }) + .unwrap_or_else(|| json!({})); + output.push(SportsStandingEntry { + name, + abbreviation, + stats, + }); + } + + Ok(SportsStandingsResponse { + league: league.to_string(), + entries: output, + }) +} + +fn collect_standings_entries(value: &Value) -> Vec { + if let Some(entries) = value + .get("standings") + .and_then(|v| v.get("entries")) + .and_then(|v| v.as_array()) + { + return entries.clone(); + } + + if let Some(children) = value.get("children").and_then(|v| v.as_array()) { + let mut entries = Vec::new(); + for child in children { + entries.extend(collect_standings_entries(child)); + } + return entries; + } + + Vec::new() +} + +fn build_date_param( + date_from: Option<&str>, + date_to: Option<&str>, +) -> Result, ToolError> { + let start = match date_from { + Some(date) => NaiveDate::parse_from_str(date, "%Y-%m-%d") + .map_err(|_| ToolError::invalid_input("date_from must be YYYY-MM-DD"))?, + None => Utc::now().date_naive(), + }; + + if let Some(date_to) = date_to { + let end = NaiveDate::parse_from_str(date_to, "%Y-%m-%d") + .map_err(|_| ToolError::invalid_input("date_to must be YYYY-MM-DD"))?; + if end < start { + return Err(ToolError::invalid_input("date_to must be >= date_from")); + } + let diff = (end - start).num_days(); + if diff > 7 { + return Ok(Some(start.format("%Y%m%d").to_string())); + } + return Ok(Some(format!( + "{}-{}", + start.format("%Y%m%d"), + end.format("%Y%m%d") + ))); + } + + Ok(Some(start.format("%Y%m%d").to_string())) +} diff --git a/src/tools/subagent.rs b/src/tools/subagent.rs index 19ed2719..8f5578e7 100644 --- a/src/tools/subagent.rs +++ b/src/tools/subagent.rs @@ -4,7 +4,7 @@ //! and retrieve results. Sub-agents run with a filtered toolset and //! inherit the workspace configuration from the main session. -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -93,8 +93,19 @@ impl SubAgentType { "apply_patch", "grep_files", "file_search", + "web.run", "web_search", + "multi_tool_use.parallel", + "weather", + "finance", + "sports", + "time", + "calculator", "exec_shell", + "exec_shell_wait", + "exec_shell_interact", + "exec_wait", + "exec_interact", "note", "todo_write", "todo_add", @@ -107,15 +118,32 @@ impl SubAgentType { "read_file", "grep_files", "file_search", + "web.run", "web_search", + "multi_tool_use.parallel", + "weather", + "finance", + "sports", + "time", + "calculator", "exec_shell", + "exec_shell_wait", + "exec_shell_interact", + "exec_wait", + "exec_interact", ], Self::Plan => vec![ "list_dir", "read_file", "grep_files", "file_search", + "web.run", "note", + "weather", + "finance", + "sports", + "time", + "calculator", "update_plan", "todo_write", "todo_add", @@ -148,6 +176,12 @@ pub struct SubAgentResult { pub duration_ms: u64, } +#[derive(Debug, Clone)] +struct SubAgentInput { + text: String, + interrupt: bool, +} + /// Runtime configuration for spawning sub-agents. #[derive(Clone)] pub struct SubAgentRuntime { @@ -188,12 +222,18 @@ pub struct SubAgent { pub steps_taken: u32, pub started_at: Instant, pub allowed_tools: Vec, + input_tx: Option>, task_handle: Option>, } impl SubAgent { /// Create a new sub-agent. - fn new(agent_type: SubAgentType, prompt: String, allowed_tools: Vec) -> Self { + fn new( + agent_type: SubAgentType, + prompt: String, + allowed_tools: Vec, + input_tx: mpsc::UnboundedSender, + ) -> Self { let id = format!("agent_{}", &Uuid::new_v4().to_string()[..8]); Self { @@ -205,6 +245,7 @@ impl SubAgent { steps_taken: 0, started_at: Instant::now(), allowed_tools, + input_tx: Some(input_tx), task_handle: None, } } @@ -280,7 +321,9 @@ impl SubAgentManager { } let tools = build_allowed_tools(&agent_type, allowed_tools, runtime.allow_shell)?; - let mut agent = SubAgent::new(agent_type.clone(), prompt.clone(), tools.clone()); + let (input_tx, input_rx) = mpsc::unbounded_channel(); + let mut agent = + SubAgent::new(agent_type.clone(), prompt.clone(), tools.clone(), input_tx); let agent_id = agent.id.clone(); let started_at = agent.started_at; let max_steps = self.max_steps; @@ -301,6 +344,7 @@ impl SubAgentManager { allowed_tools: tools, started_at, max_steps, + input_rx, }; let handle = tokio::spawn(run_subagent_task(task)); agent.task_handle = Some(handle); @@ -339,6 +383,28 @@ impl SubAgentManager { Ok(agent.snapshot()) } + /// Send input to a running sub-agent. + pub fn send_input(&mut self, agent_id: &str, text: String, interrupt: bool) -> Result<()> { + let agent = self + .agents + .get_mut(agent_id) + .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?; + + if agent.status != SubAgentStatus::Running { + return Err(anyhow!("Agent {agent_id} is not running")); + } + + let tx = agent + .input_tx + .as_ref() + .ok_or_else(|| anyhow!("Agent {agent_id} cannot accept input"))?; + + tx.send(SubAgentInput { text, interrupt }) + .map_err(|_| anyhow!("Failed to send input to agent {agent_id}"))?; + + Ok(()) + } + /// List all agents and their status. #[must_use] pub fn list(&self) -> Vec { @@ -659,6 +725,181 @@ impl ToolSpec for AgentListTool { } } +/// Tool to send input to a running sub-agent. +pub struct AgentSendInputTool { + manager: SharedSubAgentManager, + name: &'static str, +} + +impl AgentSendInputTool { + /// Create a new send-input tool. + #[must_use] + pub fn new(manager: SharedSubAgentManager, name: &'static str) -> Self { + Self { manager, name } + } +} + +#[async_trait] +impl ToolSpec for AgentSendInputTool { + fn name(&self) -> &'static str { + self.name + } + + fn description(&self) -> &'static str { + "Send input to a running sub-agent." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "ID returned by agent_spawn" + }, + "id": { + "type": "string", + "description": "Alias for agent_id" + }, + "message": { + "type": "string", + "description": "Message to deliver to the agent" + }, + "input": { + "type": "string", + "description": "Alias for message" + }, + "interrupt": { + "type": "boolean", + "description": "Prioritize this message over pending inputs" + } + }, + "required": ["message"] + }) + } + + fn capabilities(&self) -> Vec { + vec![] + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let agent_id = input + .get("agent_id") + .or_else(|| input.get("id")) + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::missing_field("agent_id"))?; + let message = input + .get("message") + .or_else(|| input.get("input")) + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::missing_field("message"))?; + let interrupt = optional_bool(&input, "interrupt", false); + + let mut manager = self.manager.lock().await; + manager + .send_input(agent_id, message.to_string(), interrupt) + .map_err(|e| ToolError::execution_failed(e.to_string()))?; + let snapshot = manager + .get_result(agent_id) + .map_err(|e| ToolError::execution_failed(e.to_string()))?; + + ToolResult::json(&snapshot).map_err(|e| ToolError::execution_failed(e.to_string())) + } +} + +/// Tool to wait for sub-agents to complete. +pub struct AgentWaitTool { + manager: SharedSubAgentManager, + name: &'static str, +} + +impl AgentWaitTool { + /// Create a new wait tool. + #[must_use] + pub fn new(manager: SharedSubAgentManager, name: &'static str) -> Self { + Self { manager, name } + } +} + +#[async_trait] +impl ToolSpec for AgentWaitTool { + fn name(&self) -> &'static str { + self.name + } + + fn description(&self) -> &'static str { + "Wait for one or more sub-agents to reach a terminal status." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { "type": "string" }, + "description": "Agent IDs to wait on" + }, + "agent_id": { + "type": "string", + "description": "Single agent ID" + }, + "timeout_ms": { + "type": "integer", + "description": "Max wait time in milliseconds (default: 30000)" + } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly] + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let timeout_ms = optional_u64(&input, "timeout_ms", 30_000).clamp(1000, 300_000); + let mut ids: Vec = Vec::new(); + if let Some(list) = input.get("ids").and_then(|v| v.as_array()) { + ids.extend( + list.iter() + .filter_map(|item| item.as_str().map(str::to_string)), + ); + } + if ids.is_empty() { + if let Some(id) = input.get("agent_id").and_then(|v| v.as_str()) { + ids.push(id.to_string()); + } + } + if ids.is_empty() { + return Err(ToolError::missing_field("ids")); + } + + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + loop { + let snapshots = { + let manager = self.manager.lock().await; + ids.iter() + .map(|id| { + manager + .get_result(id) + .map_err(|e| ToolError::execution_failed(e.to_string())) + }) + .collect::, _>>()? + }; + + let any_done = snapshots + .iter() + .any(|snapshot| snapshot.status != SubAgentStatus::Running); + if any_done || Instant::now() >= deadline { + return ToolResult::json(&snapshots) + .map_err(|e| ToolError::execution_failed(e.to_string())); + } + + tokio::time::sleep(RESULT_POLL_INTERVAL).await; + } + } +} + /// Tool to delegate a task to a specialized agent (alias for agent_spawn). pub struct DelegateToAgentTool { manager: SharedSubAgentManager, @@ -739,6 +980,7 @@ struct SubAgentTask { allowed_tools: Vec, started_at: Instant, max_steps: u32, + input_rx: mpsc::UnboundedReceiver, } #[allow(clippy::too_many_lines)] @@ -751,6 +993,7 @@ async fn run_subagent_task(task: SubAgentTask) { task.allowed_tools, task.started_at, task.max_steps, + task.input_rx, ) .await; @@ -781,6 +1024,7 @@ async fn run_subagent( allowed_tools: Vec, started_at: Instant, max_steps: u32, + mut input_rx: mpsc::UnboundedReceiver, ) -> Result { let system_prompt = agent_type.system_prompt(); let tool_registry = SubAgentToolRegistry::new( @@ -802,10 +1046,30 @@ async fn run_subagent( let mut steps = 0; let mut final_result: Option = None; + let mut pending_inputs: VecDeque = VecDeque::new(); for _step in 0..max_steps { steps += 1; + while let Ok(input) = input_rx.try_recv() { + if input.interrupt { + pending_inputs.clear(); + } + pending_inputs.push_back(input); + } + + while let Some(input) = pending_inputs.pop_front() { + if !input.text.trim().is_empty() { + messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: input.text, + cache_control: None, + }], + }); + } + } + let request = MessageRequest { model: runtime.model.clone(), messages: messages.clone(), @@ -843,7 +1107,16 @@ async fn run_subagent( }); if tool_uses.is_empty() { - break; + while let Ok(input) = input_rx.try_recv() { + if input.interrupt { + pending_inputs.clear(); + } + pending_inputs.push_back(input); + } + if pending_inputs.is_empty() { + break; + } + continue; } let mut tool_results: Vec = Vec::new(); @@ -981,7 +1254,13 @@ fn build_allowed_tools( } if !allow_shell { - tools.retain(|tool| tool != "exec_shell"); + tools.retain(|tool| { + !matches!( + tool.as_str(), + "exec_shell" | "exec_shell_wait" | "exec_shell_interact" | "exec_wait" + | "exec_interact" + ) + }); } Ok(tools) @@ -1109,6 +1388,10 @@ mod tests { fn test_allowed_tools_shell_filter() { let tools = build_allowed_tools(&SubAgentType::General, None, false).unwrap(); assert!(!tools.contains(&"exec_shell".to_string())); + assert!(!tools.contains(&"exec_shell_wait".to_string())); + assert!(!tools.contains(&"exec_shell_interact".to_string())); + assert!(!tools.contains(&"exec_wait".to_string())); + assert!(!tools.contains(&"exec_interact".to_string())); } #[test] @@ -1120,10 +1403,12 @@ mod tests { #[test] fn test_running_count_respects_limit() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); + let (input_tx, _input_rx) = mpsc::unbounded_channel(); let mut agent = SubAgent::new( SubAgentType::Explore, "prompt".to_string(), vec!["read_file".to_string()], + input_tx, ); agent.status = SubAgentStatus::Running; manager.agents.insert(agent.id.clone(), agent); diff --git a/src/tools/time.rs b/src/tools/time.rs new file mode 100644 index 00000000..fcd930e5 --- /dev/null +++ b/src/tools/time.rs @@ -0,0 +1,153 @@ +//! Time tool for returning the current time at a given UTC offset. + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str, +}; +use async_trait::async_trait; +use chrono::{DateTime, FixedOffset, Utc}; +use serde::Serialize; +use serde_json::{Value, json}; + +#[derive(Debug, Clone, Serialize)] +struct TimeRequest { + utc_offset: String, +} + +#[derive(Debug, Clone, Serialize)] +struct TimeResult { + utc_offset: String, + datetime: String, + date: String, + time: String, +} + +#[derive(Debug, Clone, Serialize)] +struct TimeResponse { + results: Vec, +} + +pub struct TimeTool; + +#[async_trait] +impl ToolSpec for TimeTool { + fn name(&self) -> &'static str { + "time" + } + + fn description(&self) -> &'static str { + "Get the current time for a given UTC offset." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "utc_offset": { + "type": "string", + "description": "UTC offset like +03:00 or -07:00" + }, + "time": { + "type": "array", + "items": { + "type": "object", + "properties": { + "utc_offset": { "type": "string" } + }, + "required": ["utc_offset"] + } + } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly] + } + + fn supports_parallel(&self) -> bool { + true + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let requests = parse_time_requests(&input)?; + let mut results = Vec::with_capacity(requests.len()); + + for req in requests { + let offset = parse_offset(&req.utc_offset)?; + let now: DateTime = Utc::now().with_timezone(&offset); + results.push(TimeResult { + utc_offset: req.utc_offset, + datetime: now.to_rfc3339(), + date: now.format("%Y-%m-%d").to_string(), + time: now.format("%H:%M:%S").to_string(), + }); + } + + ToolResult::json(&TimeResponse { results }) + .map_err(|e| ToolError::execution_failed(e.to_string())) + } +} + +fn parse_time_requests(input: &Value) -> Result, ToolError> { + if let Some(list) = input.get("time").and_then(|v| v.as_array()) { + let mut requests = Vec::new(); + for item in list { + let offset = required_str(item, "utc_offset")?.to_string(); + requests.push(TimeRequest { utc_offset: offset }); + } + if requests.is_empty() { + return Err(ToolError::invalid_input("time list is empty")); + } + return Ok(requests); + } + + let offset = required_str(input, "utc_offset")?.to_string(); + Ok(vec![TimeRequest { utc_offset: offset }]) +} + +fn parse_offset(raw: &str) -> Result { + let raw = raw.trim(); + if raw.len() != 6 { + return Err(ToolError::invalid_input( + "utc_offset must be formatted like +03:00", + )); + } + let sign = match &raw[0..1] { + "+" => 1, + "-" => -1, + _ => { + return Err(ToolError::invalid_input( + "utc_offset must start with + or -", + )) + } + }; + let hours: i32 = raw[1..3] + .parse() + .map_err(|_| ToolError::invalid_input("Invalid utc_offset hours"))?; + let minutes: i32 = raw[4..6] + .parse() + .map_err(|_| ToolError::invalid_input("Invalid utc_offset minutes"))?; + if &raw[3..4] != ":" { + return Err(ToolError::invalid_input( + "utc_offset must include ':' separator", + )); + } + let total_seconds = sign * (hours * 3600 + minutes * 60); + FixedOffset::east_opt(total_seconds) + .ok_or_else(|| ToolError::invalid_input("Invalid utc_offset range")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_offset() { + let offset = parse_offset("+03:00").expect("offset"); + assert_eq!(offset.local_minus_utc(), 3 * 3600); + } +} diff --git a/src/tools/user_input.rs b/src/tools/user_input.rs new file mode 100644 index 00000000..610c9b6c --- /dev/null +++ b/src/tools/user_input.rs @@ -0,0 +1,256 @@ +//! Tool and types for requesting user input via the TUI. + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, +}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserInputOption { + pub label: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserInputQuestion { + pub header: String, + pub id: String, + pub question: String, + pub options: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserInputRequest { + pub questions: Vec, +} + +impl UserInputRequest { + pub fn from_value(value: &Value) -> Result { + let request: UserInputRequest = serde_json::from_value(value.clone()).map_err(|e| { + ToolError::invalid_input(format!("Invalid request_user_input payload: {e}")) + })?; + request.validate()?; + Ok(request) + } + + pub fn validate(&self) -> Result<(), ToolError> { + if self.questions.is_empty() { + return Err(ToolError::invalid_input( + "request_user_input.questions must be non-empty", + )); + } + if self.questions.len() > 3 { + return Err(ToolError::invalid_input( + "request_user_input.questions must contain 1 to 3 items", + )); + } + for q in &self.questions { + if q.header.trim().is_empty() { + return Err(ToolError::invalid_input( + "request_user_input.questions.header cannot be empty", + )); + } + if q.id.trim().is_empty() { + return Err(ToolError::invalid_input( + "request_user_input.questions.id cannot be empty", + )); + } + if q.question.trim().is_empty() { + return Err(ToolError::invalid_input( + "request_user_input.questions.question cannot be empty", + )); + } + if q.options.len() < 2 || q.options.len() > 3 { + return Err(ToolError::invalid_input( + "request_user_input.questions.options must contain 2 or 3 items", + )); + } + for opt in &q.options { + if opt.label.trim().is_empty() { + return Err(ToolError::invalid_input( + "request_user_input option label cannot be empty", + )); + } + if opt.description.trim().is_empty() { + return Err(ToolError::invalid_input( + "request_user_input option description cannot be empty", + )); + } + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserInputAnswer { + pub id: String, + pub label: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserInputResponse { + pub answers: Vec, +} + +pub struct RequestUserInputTool; + +#[async_trait] +impl ToolSpec for RequestUserInputTool { + fn name(&self) -> &'static str { + "request_user_input" + } + + fn description(&self) -> &'static str { + "Ask the user 1-3 short questions and return their selections." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "header": { "type": "string" }, + "id": { "type": "string" }, + "question": { "type": "string" }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { "type": "string" }, + "description": { "type": "string" } + }, + "required": ["label", "description"] + }, + "minItems": 2, + "maxItems": 3 + } + }, + "required": ["header", "id", "question", "options"] + }, + "minItems": 1, + "maxItems": 3 + } + }, + "required": ["questions"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, _input: Value, _context: &ToolContext) -> Result { + Err(ToolError::execution_failed( + "request_user_input must be handled by the engine", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_request_shape() { + let request = UserInputRequest { + questions: vec![UserInputQuestion { + header: "Pick".to_string(), + id: "choice".to_string(), + question: "Which option?".to_string(), + options: vec![ + UserInputOption { + label: "A".to_string(), + description: "Option A".to_string(), + }, + UserInputOption { + label: "B".to_string(), + description: "Option B".to_string(), + }, + ], + }], + }; + assert!(request.validate().is_ok()); + } + + #[test] + fn rejects_too_many_questions() { + let request = UserInputRequest { + questions: vec![ + UserInputQuestion { + header: "Q1".to_string(), + id: "q1".to_string(), + question: "?".to_string(), + options: vec![ + UserInputOption { + label: "A".to_string(), + description: "A".to_string(), + }, + UserInputOption { + label: "B".to_string(), + description: "B".to_string(), + }, + ], + }, + UserInputQuestion { + header: "Q2".to_string(), + id: "q2".to_string(), + question: "?".to_string(), + options: vec![ + UserInputOption { + label: "A".to_string(), + description: "A".to_string(), + }, + UserInputOption { + label: "B".to_string(), + description: "B".to_string(), + }, + ], + }, + UserInputQuestion { + header: "Q3".to_string(), + id: "q3".to_string(), + question: "?".to_string(), + options: vec![ + UserInputOption { + label: "A".to_string(), + description: "A".to_string(), + }, + UserInputOption { + label: "B".to_string(), + description: "B".to_string(), + }, + ], + }, + UserInputQuestion { + header: "Q4".to_string(), + id: "q4".to_string(), + question: "?".to_string(), + options: vec![ + UserInputOption { + label: "A".to_string(), + description: "A".to_string(), + }, + UserInputOption { + label: "B".to_string(), + description: "B".to_string(), + }, + ], + }, + ], + }; + assert!(request.validate().is_err()); + } +} diff --git a/src/tools/weather.rs b/src/tools/weather.rs new file mode 100644 index 00000000..4b991b13 --- /dev/null +++ b/src/tools/weather.rs @@ -0,0 +1,344 @@ +//! Weather tool backed by Open-Meteo (no API key required). + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64, + optional_str, required_str, +}; +use async_trait::async_trait; +use chrono::{NaiveDate, Utc}; +use serde::Serialize; +use serde_json::{Value, json}; +use std::time::Duration; + +const DEFAULT_DAYS: u64 = 7; +const MAX_DAYS: u64 = 14; +const TIMEOUT_MS: u64 = 15_000; +const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; + +#[derive(Debug, Clone, Serialize)] +struct WeatherRequest { + location: String, + start: String, + duration: u64, +} + +#[derive(Debug, Clone, Serialize)] +struct WeatherDay { + date: String, + temp_max_c: f64, + temp_min_c: f64, + temp_max_f: f64, + temp_min_f: f64, + precip_mm: f64, +} + +#[derive(Debug, Clone, Serialize)] +struct WeatherResult { + location: String, + resolved_name: String, + latitude: f64, + longitude: f64, + timezone: String, + source: String, + days: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct WeatherResponse { + results: Vec, +} + +pub struct WeatherTool; + +#[async_trait] +impl ToolSpec for WeatherTool { + fn name(&self) -> &'static str { + "weather" + } + + fn description(&self) -> &'static str { + "Get a daily weather forecast for a location." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "location": { "type": "string" }, + "start": { "type": "string", "description": "YYYY-MM-DD" }, + "duration": { "type": "integer", "description": "Number of days" }, + "weather": { + "type": "array", + "items": { + "type": "object", + "properties": { + "location": { "type": "string" }, + "start": { "type": "string" }, + "duration": { "type": "integer" } + }, + "required": ["location"] + } + } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly, ToolCapability::Network] + } + + fn supports_parallel(&self) -> bool { + true + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let requests = parse_weather_requests(&input)?; + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(TIMEOUT_MS)) + .user_agent(USER_AGENT) + .build() + .map_err(|e| ToolError::execution_failed(format!("Failed to build HTTP client: {e}")))?; + + let mut results = Vec::with_capacity(requests.len()); + for req in requests { + let geo = geocode_location(&client, &req.location).await?; + let forecast = fetch_forecast(&client, &geo, &req.start, req.duration).await?; + results.push(WeatherResult { + location: req.location, + resolved_name: geo.name, + latitude: geo.latitude, + longitude: geo.longitude, + timezone: forecast.timezone, + source: "open-meteo".to_string(), + days: forecast.days, + }); + } + + ToolResult::json(&WeatherResponse { results }) + .map_err(|e| ToolError::execution_failed(e.to_string())) + } +} + +fn parse_weather_requests(input: &Value) -> Result, ToolError> { + if let Some(list) = input.get("weather").and_then(|v| v.as_array()) { + let mut requests = Vec::new(); + for item in list { + let location = required_str(item, "location")?.to_string(); + let start = optional_str(item, "start") + .map(|s| s.to_string()) + .unwrap_or_else(|| today_string()); + let duration = optional_u64(item, "duration", DEFAULT_DAYS).clamp(1, MAX_DAYS); + requests.push(WeatherRequest { + location, + start, + duration, + }); + } + if requests.is_empty() { + return Err(ToolError::invalid_input("weather list is empty")); + } + return Ok(requests); + } + + let location = required_str(input, "location")?.to_string(); + let start = optional_str(input, "start") + .map(|s| s.to_string()) + .unwrap_or_else(|| today_string()); + let duration = optional_u64(input, "duration", DEFAULT_DAYS).clamp(1, MAX_DAYS); + + Ok(vec![WeatherRequest { + location, + start, + duration, + }]) +} + +fn today_string() -> String { + Utc::now().date_naive().format("%Y-%m-%d").to_string() +} + +#[derive(Debug)] +struct GeoResult { + name: String, + latitude: f64, + longitude: f64, +} + +async fn geocode_location(client: &reqwest::Client, location: &str) -> Result { + let encoded = url_encode(location); + let url = format!( + "https://geocoding-api.open-meteo.com/v1/search?name={encoded}&count=1&language=en&format=json" + ); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| ToolError::execution_failed(format!("Geocoding request failed: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?; + if !status.is_success() { + return Err(ToolError::execution_failed(format!( + "Geocoding failed: HTTP {}", + status.as_u16() + ))); + } + let json: Value = serde_json::from_str(&body) + .map_err(|e| ToolError::execution_failed(format!("Invalid geocoding JSON: {e}")))?; + let results = json + .get("results") + .and_then(|v| v.as_array()) + .ok_or_else(|| ToolError::execution_failed("No geocoding results"))?; + let first = results + .first() + .ok_or_else(|| ToolError::execution_failed("No geocoding results"))?; + let name = first + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(location) + .to_string(); + let latitude = first + .get("latitude") + .and_then(|v| v.as_f64()) + .ok_or_else(|| ToolError::execution_failed("Missing latitude"))?; + let longitude = first + .get("longitude") + .and_then(|v| v.as_f64()) + .ok_or_else(|| ToolError::execution_failed("Missing longitude"))?; + Ok(GeoResult { + name, + latitude, + longitude, + }) +} + +#[derive(Debug)] +struct ForecastResult { + timezone: String, + days: Vec, +} + +async fn fetch_forecast( + client: &reqwest::Client, + geo: &GeoResult, + start: &str, + duration: u64, +) -> Result { + let start_date = NaiveDate::parse_from_str(start, "%Y-%m-%d") + .map_err(|_| ToolError::invalid_input("start must be YYYY-MM-DD"))?; + let end_date = start_date + .checked_add_signed(chrono::Duration::days((duration as i64).saturating_sub(1))) + .ok_or_else(|| ToolError::invalid_input("Invalid duration"))?; + let url = format!( + "https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto&start_date={start}&end_date={end}", + lat = geo.latitude, + lon = geo.longitude, + start = start_date.format("%Y-%m-%d"), + end = end_date.format("%Y-%m-%d"), + ); + + let resp = client + .get(&url) + .send() + .await + .map_err(|e| ToolError::execution_failed(format!("Forecast request failed: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?; + if !status.is_success() { + return Err(ToolError::execution_failed(format!( + "Forecast failed: HTTP {}", + status.as_u16() + ))); + } + + let json: Value = serde_json::from_str(&body) + .map_err(|e| ToolError::execution_failed(format!("Invalid forecast JSON: {e}")))?; + let timezone = json + .get("timezone") + .and_then(|v| v.as_str()) + .unwrap_or("UTC") + .to_string(); + let daily = json + .get("daily") + .and_then(|v| v.as_object()) + .ok_or_else(|| ToolError::execution_failed("Missing daily forecast"))?; + + let dates = daily + .get("time") + .and_then(|v| v.as_array()) + .ok_or_else(|| ToolError::execution_failed("Missing daily time"))?; + let maxes = daily + .get("temperature_2m_max") + .and_then(|v| v.as_array()) + .ok_or_else(|| ToolError::execution_failed("Missing temperature_2m_max"))?; + let mins = daily + .get("temperature_2m_min") + .and_then(|v| v.as_array()) + .ok_or_else(|| ToolError::execution_failed("Missing temperature_2m_min"))?; + let precips = daily + .get("precipitation_sum") + .and_then(|v| v.as_array()) + .ok_or_else(|| ToolError::execution_failed("Missing precipitation_sum"))?; + + let mut days = Vec::new(); + for idx in 0..dates.len() { + let date = dates + .get(idx) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let max_c = maxes + .get(idx) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let min_c = mins + .get(idx) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let precip = precips + .get(idx) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + days.push(WeatherDay { + date, + temp_max_c: max_c, + temp_min_c: min_c, + temp_max_f: c_to_f(max_c), + temp_min_f: c_to_f(min_c), + precip_mm: precip, + }); + } + + Ok(ForecastResult { timezone, days }) +} + +fn c_to_f(c: f64) -> f64 { + c * 9.0 / 5.0 + 32.0 +} + +fn url_encode(input: &str) -> String { + let mut encoded = String::new(); + for ch in input.bytes() { + match ch { + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'_' + | b'.' + | b'~' => encoded.push(ch as char), + b' ' => encoded.push('+'), + _ => encoded.push_str(&format!("%{ch:02X}")), + } + } + encoded +} diff --git a/src/tools/web_run.rs b/src/tools/web_run.rs new file mode 100644 index 00000000..73079994 --- /dev/null +++ b/src/tools/web_run.rs @@ -0,0 +1,1069 @@ +//! Web browsing tool with multi-command support (search/open/click/find/screenshot). +//! +//! This mirrors the Codex harness `web.run` interface so models can use a single +//! tool call to perform multiple web actions and cite sources with ref_ids. + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64, + required_str, +}; +use async_trait::async_trait; +use regex::Regex; +use serde::Serialize; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +const DEFAULT_MAX_RESULTS: usize = 5; +const MAX_RESULTS: usize = 10; +const DEFAULT_TIMEOUT_MS: u64 = 15_000; +const DEFAULT_OPEN_TIMEOUT_MS: u64 = 20_000; +const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; + +static WEB_RUN_STATE: OnceLock> = OnceLock::new(); + +#[derive(Default)] +struct WebRunState { + next_turn: u64, + pages: HashMap, +} + +#[derive(Debug, Clone, Serialize)] +struct WebLink { + id: usize, + url: String, + text: String, +} + +#[derive(Debug, Clone)] +struct WebPage { + url: String, + title: Option, + content_type: Option, + lines: Vec, + links: Vec, + pdf_pages: Option>>, +} + +#[derive(Debug, Clone, Copy)] +enum ResponseLength { + Short, + Medium, + Long, +} + +impl ResponseLength { + fn from_input(input: Option<&Value>) -> Self { + let raw = input.and_then(|v| v.as_str()).unwrap_or("medium"); + match raw.to_lowercase().as_str() { + "short" => Self::Short, + "long" => Self::Long, + _ => Self::Medium, + } + } + + fn view_lines(self) -> usize { + match self { + Self::Short => 40, + Self::Medium => 80, + Self::Long => 160, + } + } + + fn wrap_width(self) -> usize { + match self { + Self::Short => 88, + Self::Medium => 110, + Self::Long => 140, + } + } + + fn max_results(self) -> usize { + match self { + Self::Short => 5, + Self::Medium => 8, + Self::Long => 10, + } + } + + fn max_find_matches(self) -> usize { + match self { + Self::Short => 8, + Self::Medium => 15, + Self::Long => 30, + } + } +} + +#[derive(Debug, Clone, Serialize)] +struct SearchEntry { + title: String, + url: String, + snippet: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct SearchResult { + ref_id: String, + query: String, + source: String, + count: usize, + results: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + warning: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct PageViewResult { + ref_id: String, + url: String, + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + content_type: Option, + line_start: usize, + line_end: usize, + total_lines: usize, + content: String, + links: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct FindMatch { + line: usize, + text: String, +} + +#[derive(Debug, Clone, Serialize)] +struct FindResult { + ref_id: String, + pattern: String, + count: usize, + matches: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct ScreenshotResult { + ref_id: String, + pageno: usize, + total_pages: usize, + content: String, +} + +#[derive(Debug, Clone, Serialize, Default)] +struct WebRunOutput { + #[serde(skip_serializing_if = "Option::is_none")] + search_query: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + open: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + click: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + find: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + screenshot: Option>, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + warnings: Vec, +} + +pub struct WebRunTool; + +#[async_trait] +impl ToolSpec for WebRunTool { + fn name(&self) -> &'static str { + "web.run" + } + + fn description(&self) -> &'static str { + "Browse the web (search/open/click/find/screenshot) and return structured results with ref_ids for citations." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "search_query": { + "type": "array", + "items": { + "type": "object", + "properties": { + "q": { "type": "string" }, + "recency": { "type": "integer" }, + "domains": { "type": "array", "items": { "type": "string" } } + }, + "required": ["q"] + } + }, + "open": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ref_id": { "type": "string" }, + "lineno": { "type": "integer" } + }, + "required": ["ref_id"] + } + }, + "click": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ref_id": { "type": "string" }, + "id": { "type": "integer" } + }, + "required": ["ref_id", "id"] + } + }, + "find": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ref_id": { "type": "string" }, + "pattern": { "type": "string" } + }, + "required": ["ref_id", "pattern"] + } + }, + "screenshot": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ref_id": { "type": "string" }, + "pageno": { "type": "integer" } + }, + "required": ["ref_id", "pageno"] + } + }, + "response_length": { + "type": "string", + "enum": ["short", "medium", "long"], + "description": "Controls result verbosity" + } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly, ToolCapability::Network] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let response_length = ResponseLength::from_input(input.get("response_length")); + let mut output = WebRunOutput::default(); + + let turn = with_state(|state| { + let current = state.next_turn; + state.next_turn = state.next_turn.saturating_add(1); + current + }); + + let mut search_counter = 0usize; + let mut view_counter = 0usize; + let mut click_counter = 0usize; + + if let Some(searches) = input.get("search_query").and_then(|v| v.as_array()) { + let mut results = Vec::new(); + for search in searches { + let query = required_str(search, "q")?.trim().to_string(); + if query.is_empty() { + continue; + } + let recency = optional_u64(search, "recency", 0); + let max_results = usize::try_from(optional_u64( + search, + "max_results", + response_length.max_results() as u64, + )) + .unwrap_or(response_length.max_results()) + .clamp(1, MAX_RESULTS); + let timeout_ms = optional_u64(search, "timeout_ms", DEFAULT_TIMEOUT_MS).min(60_000); + + let domains = search + .get("domains") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect::>() + }) + .unwrap_or_default(); + + let (entries, warning) = run_search(&query, max_results, timeout_ms, &domains).await?; + let mut warnings = Vec::new(); + if recency > 0 { + warnings.push(format!( + "Recency filter not enforced (requested last {recency} days)" + )); + } + if let Some(w) = warning { + warnings.push(w); + } + search_counter += 1; + let ref_id = format!("turn{turn}search{search_counter}"); + + let page = page_from_search(&query, &entries); + store_page(&ref_id, page); + + results.push(SearchResult { + ref_id, + query, + source: "duckduckgo".to_string(), + count: entries.len(), + results: entries, + warning: if warnings.is_empty() { + None + } else { + Some(warnings.join("; ")) + }, + }); + } + if !results.is_empty() { + output.search_query = Some(results); + } + } + + if let Some(opens) = input.get("open").and_then(|v| v.as_array()) { + let mut views = Vec::new(); + for open in opens { + let ref_id = required_str(open, "ref_id")?.to_string(); + let lineno = optional_u64(open, "lineno", 1).max(1) as usize; + + let page = resolve_or_fetch_page(&ref_id, DEFAULT_OPEN_TIMEOUT_MS).await?; + view_counter += 1; + let view_ref = format!("turn{turn}view{view_counter}"); + store_page(&view_ref, page.clone()); + + let view = render_view(&view_ref, &page, lineno, response_length); + views.push(view); + } + if !views.is_empty() { + output.open = Some(views); + } + } + + if let Some(clicks) = input.get("click").and_then(|v| v.as_array()) { + let mut views = Vec::new(); + for click in clicks { + let ref_id = required_str(click, "ref_id")?.to_string(); + let link_id = optional_u64(click, "id", 0) as usize; + if link_id == 0 { + return Err(ToolError::invalid_input("click.id must be >= 1")); + } + let page = get_page(&ref_id).ok_or_else(|| { + ToolError::invalid_input(format!("Unknown ref_id '{ref_id}'")) + })?; + let link = page + .links + .iter() + .find(|l| l.id == link_id) + .ok_or_else(|| { + ToolError::invalid_input(format!( + "Link id {link_id} not found for ref_id '{ref_id}'" + )) + })?; + let target = link.url.clone(); + let fetched = resolve_or_fetch_page(&target, DEFAULT_OPEN_TIMEOUT_MS).await?; + click_counter += 1; + let click_ref = format!("turn{turn}click{click_counter}"); + store_page(&click_ref, fetched.clone()); + let view = render_view(&click_ref, &fetched, 1, response_length); + views.push(view); + } + if !views.is_empty() { + output.click = Some(views); + } + } + + if let Some(find_requests) = input.get("find").and_then(|v| v.as_array()) { + let mut finds = Vec::new(); + for find_req in find_requests { + let ref_id = required_str(find_req, "ref_id")?.to_string(); + let pattern = required_str(find_req, "pattern")?.to_string(); + let page = get_page(&ref_id).ok_or_else(|| { + ToolError::invalid_input(format!("Unknown ref_id '{ref_id}'")) + })?; + let find_result = find_in_page(&ref_id, &pattern, &page, response_length); + finds.push(find_result); + } + if !finds.is_empty() { + output.find = Some(finds); + } + } + + if let Some(shots) = input.get("screenshot").and_then(|v| v.as_array()) { + let mut screenshots = Vec::new(); + for shot in shots { + let ref_id = required_str(shot, "ref_id")?.to_string(); + let pageno = optional_u64(shot, "pageno", 0) as usize; + let page = get_page(&ref_id).ok_or_else(|| { + ToolError::invalid_input(format!("Unknown ref_id '{ref_id}'")) + })?; + let screenshot = screenshot_page(&ref_id, pageno, &page)?; + screenshots.push(screenshot); + } + if !screenshots.is_empty() { + output.screenshot = Some(screenshots); + } + } + + ToolResult::json(&output).map_err(|e| ToolError::execution_failed(e.to_string())) + } +} + +fn with_state(f: impl FnOnce(&mut WebRunState) -> T) -> T { + let lock = WEB_RUN_STATE.get_or_init(|| Mutex::new(WebRunState::default())); + let mut state = lock + .lock() + .expect("web run state mutex should not be poisoned"); + f(&mut state) +} + +fn store_page(ref_id: &str, page: WebPage) { + with_state(|state| { + state.pages.insert(ref_id.to_string(), page); + }); +} + +fn get_page(ref_id: &str) -> Option { + with_state(|state| state.pages.get(ref_id).cloned()) +} + +async fn resolve_or_fetch_page(ref_id: &str, timeout_ms: u64) -> Result { + if let Some(page) = get_page(ref_id) { + return Ok(page); + } + if looks_like_url(ref_id) { + return fetch_page(ref_id, timeout_ms).await; + } + Err(ToolError::invalid_input(format!( + "Unknown ref_id '{ref_id}'" + ))) +} + +fn looks_like_url(value: &str) -> bool { + value.starts_with("http://") || value.starts_with("https://") +} + +async fn run_search( + query: &str, + max_results: usize, + timeout_ms: u64, + domains: &[String], +) -> Result<(Vec, Option), ToolError> { + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(timeout_ms)) + .user_agent(USER_AGENT) + .build() + .map_err(|e| ToolError::execution_failed(format!("Failed to build HTTP client: {e}")))?; + + let encoded = url_encode(query); + let url = format!("https://html.duckduckgo.com/html/?q={encoded}"); + let resp = client + .get(&url) + .header( + "Accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + ) + .header("Accept-Language", "en-US,en;q=0.5") + .send() + .await + .map_err(|e| ToolError::execution_failed(format!("Web search request failed: {e}")))?; + + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?; + + if !status.is_success() { + return Err(ToolError::execution_failed(format!( + "Web search failed: HTTP {}", + status.as_u16() + ))); + } + + let mut results = parse_duckduckgo_results(&body, max_results); + let warning = if !domains.is_empty() { + let before = results.len(); + results.retain(|entry| domain_matches(&entry.url, domains)); + if before != results.len() { + Some("Filtered search results by domain list".to_string()) + } else { + None + } + } else { + None + }; + + Ok((results, warning)) +} + +fn domain_matches(url: &str, domains: &[String]) -> bool { + if domains.is_empty() { + return true; + } + let Ok(parsed) = reqwest::Url::parse(url) else { + return false; + }; + let Some(host) = parsed.host_str() else { + return false; + }; + domains.iter().any(|domain| { + let domain = domain.trim_start_matches("www."); + host == domain || host.ends_with(&format!(".{domain}")) + }) +} + +fn page_from_search(query: &str, results: &[SearchEntry]) -> WebPage { + let mut lines = Vec::new(); + let mut links = Vec::new(); + + lines.push(format!("Search results for: {query}")); + for (idx, entry) in results.iter().enumerate() { + let id = idx + 1; + links.push(WebLink { + id, + url: entry.url.clone(), + text: entry.title.clone(), + }); + lines.push(format!("{}. [{}] {}", id, id, entry.title)); + if let Some(snippet) = entry.snippet.as_ref() { + if !snippet.trim().is_empty() { + lines.push(format!(" {snippet}")); + } + } + lines.push(format!(" {url}", url = entry.url)); + } + + WebPage { + url: "https://html.duckduckgo.com/html/".to_string(), + title: Some("Search Results".to_string()), + content_type: Some("text/html".to_string()), + lines, + links, + pdf_pages: None, + } +} + +async fn fetch_page(url: &str, timeout_ms: u64) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(timeout_ms)) + .user_agent(USER_AGENT) + .build() + .map_err(|e| ToolError::execution_failed(format!("Failed to build HTTP client: {e}")))?; + + let resp = client + .get(url) + .header( + "Accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + ) + .header("Accept-Language", "en-US,en;q=0.5") + .send() + .await + .map_err(|e| ToolError::execution_failed(format!("Web request failed: {e}")))?; + + let status = resp.status(); + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let bytes = resp + .bytes() + .await + .map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?; + + if !status.is_success() { + return Err(ToolError::execution_failed(format!( + "Web request failed: HTTP {}", + status.as_u16() + ))); + } + + if is_pdf(&content_type, url) { + return parse_pdf_page(url, content_type, &bytes); + } + + let body = String::from_utf8_lossy(&bytes).to_string(); + let (lines, links, title) = parse_html(&body, url); + + Ok(WebPage { + url: url.to_string(), + title, + content_type, + lines, + links, + pdf_pages: None, + }) +} + +fn is_pdf(content_type: &Option, url: &str) -> bool { + if let Some(ct) = content_type { + if ct.to_lowercase().contains("application/pdf") { + return true; + } + } + url.to_lowercase().ends_with(".pdf") +} + +fn parse_pdf_page( + url: &str, + content_type: Option, + bytes: &[u8], +) -> Result { + let text = pdf_extract_text(bytes)?; + let pages = split_pdf_pages(&text); + let lines = pages + .get(0) + .cloned() + .unwrap_or_else(Vec::new); + + Ok(WebPage { + url: url.to_string(), + title: Some("PDF Document".to_string()), + content_type, + lines, + links: Vec::new(), + pdf_pages: Some(pages), + }) +} + +fn pdf_extract_text(bytes: &[u8]) -> Result { + pdf_extract::extract_text_from_mem(bytes) + .map_err(|e| ToolError::execution_failed(format!("PDF extract failed: {e}"))) +} + +fn split_pdf_pages(text: &str) -> Vec> { + let raw_pages: Vec<&str> = text.split('\x0C').collect(); + raw_pages + .iter() + .map(|page| { + page.lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .map(|line| line.to_string()) + .collect::>() + }) + .collect() +} + +fn render_view(ref_id: &str, page: &WebPage, lineno: usize, response: ResponseLength) -> PageViewResult { + let total = page.lines.len(); + let view_lines = response.view_lines(); + let start = if total == 0 { + 1 + } else if lineno > total { + total.saturating_sub(view_lines.saturating_sub(1)).max(1) + } else { + lineno + }; + let end = if total == 0 { + 0 + } else { + (start + view_lines - 1).min(total) + }; + + let content = if total == 0 { + "(no content)".to_string() + } else { + render_lines(&page.lines, start, end) + }; + + PageViewResult { + ref_id: ref_id.to_string(), + url: page.url.clone(), + title: page.title.clone(), + content_type: page.content_type.clone(), + line_start: start, + line_end: end, + total_lines: total, + content, + links: page.links.clone(), + } +} + +fn render_lines(lines: &[String], start: usize, end: usize) -> String { + lines + .iter() + .enumerate() + .filter_map(|(idx, line)| { + let line_no = idx + 1; + if line_no < start || line_no > end { + return None; + } + Some(format!("{:>4} {}", line_no, line)) + }) + .collect::>() + .join("\n") +} + +fn find_in_page( + ref_id: &str, + pattern: &str, + page: &WebPage, + response: ResponseLength, +) -> FindResult { + let needle = pattern.to_lowercase(); + let mut matches = Vec::new(); + for (idx, line) in page.lines.iter().enumerate() { + if line.to_lowercase().contains(&needle) { + matches.push(FindMatch { + line: idx + 1, + text: line.clone(), + }); + } + if matches.len() >= response.max_find_matches() { + break; + } + } + + FindResult { + ref_id: ref_id.to_string(), + pattern: pattern.to_string(), + count: matches.len(), + matches, + } +} + +fn screenshot_page(ref_id: &str, pageno: usize, page: &WebPage) -> Result { + let pages = page + .pdf_pages + .as_ref() + .ok_or_else(|| ToolError::invalid_input("screenshot is only supported for PDF pages"))?; + if pages.is_empty() { + return Err(ToolError::execution_failed("PDF has no pages")); + } + if pageno >= pages.len() { + return Err(ToolError::invalid_input(format!( + "pageno {pageno} out of range (0..{max})", + max = pages.len().saturating_sub(1) + ))); + } + let content = pages[pageno].join("\n"); + Ok(ScreenshotResult { + ref_id: ref_id.to_string(), + pageno, + total_pages: pages.len(), + content, + }) +} + +// === HTML Parsing === + +static ANCHOR_RE: OnceLock = OnceLock::new(); +static TAG_RE: OnceLock = OnceLock::new(); +static BLOCK_RE: OnceLock = OnceLock::new(); +static SCRIPT_RE: OnceLock = OnceLock::new(); +static STYLE_RE: OnceLock = OnceLock::new(); +static TITLE_RE: OnceLock = OnceLock::new(); +static SNIPPET_RE: OnceLock = OnceLock::new(); +static SEARCH_TITLE_RE: OnceLock = OnceLock::new(); + +fn get_anchor_re() -> &'static Regex { + ANCHOR_RE.get_or_init(|| { + Regex::new(r#"(?is)]*href\s*=\s*['\"]([^'\"]+)['\"][^>]*>(.*?)"#) + .expect("anchor regex") + }) +} + +fn get_tag_re() -> &'static Regex { + TAG_RE.get_or_init(|| Regex::new(r"<[^>]+>").expect("tag regex")) +} + +fn get_block_re() -> &'static Regex { + BLOCK_RE.get_or_init(|| { + Regex::new(r"(?is)]*>") + .expect("block regex") + }) +} + +fn get_script_re() -> &'static Regex { + SCRIPT_RE.get_or_init(|| Regex::new(r"(?is)]*>.*?").unwrap()) +} + +fn get_style_re() -> &'static Regex { + STYLE_RE.get_or_init(|| Regex::new(r"(?is)]*>.*?").unwrap()) +} + +fn get_title_re() -> &'static Regex { + TITLE_RE.get_or_init(|| Regex::new(r"(?is)]*>(.*?)").unwrap()) +} + +fn get_search_title_re() -> &'static Regex { + SEARCH_TITLE_RE.get_or_init(|| { + Regex::new(r#"]*class=\"result__a\"[^>]*href=\"([^\"]+)\"[^>]*>(.*?)"#) + .expect("title regex pattern is valid") + }) +} + +fn get_search_snippet_re() -> &'static Regex { + SNIPPET_RE.get_or_init(|| { + Regex::new( + r#"]*class=\"result__snippet\"[^>]*>(.*?)|]*class=\"result__snippet\"[^>]*>(.*?)"#, + ) + .expect("snippet regex pattern is valid") + }) +} + +fn parse_html(html: &str, base_url: &str) -> (Vec, Vec, Option) { + let title = extract_title(html); + let without_scripts = get_script_re().replace_all(html, "").to_string(); + let without_styles = get_style_re().replace_all(&without_scripts, "").to_string(); + + let (with_links, links) = replace_links(&without_styles, base_url); + let with_breaks = get_block_re().replace_all(&with_links, "\n").to_string(); + let without_tags = get_tag_re().replace_all(&with_breaks, "").to_string(); + let decoded = decode_html_entities(&without_tags); + + let mut lines = Vec::new(); + for line in decoded.lines() { + let trimmed = normalize_whitespace(line); + if trimmed.is_empty() { + continue; + } + for wrapped in wrap_line(&trimmed, ResponseLength::Medium.wrap_width()) { + lines.push(wrapped); + } + } + + (lines, links, title) +} + +fn extract_title(html: &str) -> Option { + let re = get_title_re(); + let cap = re.captures(html)?; + let raw = cap.get(1)?.as_str(); + let cleaned = normalize_whitespace(&decode_html_entities(raw)); + if cleaned.is_empty() { + None + } else { + Some(cleaned) + } +} + +fn replace_links(html: &str, base_url: &str) -> (String, Vec) { + let re = get_anchor_re(); + let mut links = Vec::new(); + let mut output = String::with_capacity(html.len()); + let mut last = 0; + + for cap in re.captures_iter(html) { + let Some(full) = cap.get(0) else { continue }; + let Some(href) = cap.get(1) else { continue }; + let Some(text_match) = cap.get(2) else { continue }; + + output.push_str(&html[last..full.start()]); + let text = normalize_whitespace(&strip_tags(text_match.as_str())); + let resolved = resolve_url(base_url, href.as_str()); + if !text.is_empty() { + let id = links.len() + 1; + links.push(WebLink { + id, + url: resolved.clone(), + text: text.clone(), + }); + output.push_str(&format!("[{}] {}", id, text)); + } else { + output.push_str(&resolved); + } + last = full.end(); + } + + output.push_str(&html[last..]); + (output, links) +} + +fn resolve_url(base: &str, href: &str) -> String { + if href.starts_with("http://") || href.starts_with("https://") { + return href.to_string(); + } + if href.starts_with("//") { + return format!("https:{href}"); + } + if let Ok(base_url) = reqwest::Url::parse(base) { + if let Ok(joined) = base_url.join(href) { + return joined.to_string(); + } + } + href.to_string() +} + +fn strip_tags(text: &str) -> String { + get_tag_re().replace_all(text, "").to_string() +} + +fn normalize_whitespace(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +fn wrap_line(text: &str, width: usize) -> Vec { + if text.len() <= width { + return vec![text.to_string()]; + } + let mut lines = Vec::new(); + let mut current = String::new(); + for word in text.split_whitespace() { + if current.is_empty() { + current.push_str(word); + } else if current.len() + word.len() + 1 <= width { + current.push(' '); + current.push_str(word); + } else { + lines.push(current); + current = word.to_string(); + } + } + if !current.is_empty() { + lines.push(current); + } + lines +} + +fn decode_html_entities(text: &str) -> String { + text.replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace(" ", " ") +} + +fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec { + let title_re = get_search_title_re(); + let snippet_re = get_search_snippet_re(); + let snippets: Vec = snippet_re + .captures_iter(html) + .filter_map(|cap| cap.get(1).or_else(|| cap.get(2))) + .map(|m| normalize_whitespace(&decode_html_entities(&strip_tags(m.as_str())))) + .collect(); + + let mut results = Vec::new(); + for (idx, cap) in title_re.captures_iter(html).enumerate() { + if results.len() >= max_results { + break; + } + let href = cap.get(1).map(|m| m.as_str()).unwrap_or(""); + let title_raw = cap.get(2).map(|m| m.as_str()).unwrap_or(""); + let title = normalize_whitespace(&decode_html_entities(&strip_tags(title_raw))); + if title.is_empty() { + continue; + } + let url = normalize_search_url(href); + let snippet = snippets + .get(idx) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()); + + results.push(SearchEntry { title, url, snippet }); + } + + results +} + +fn normalize_search_url(href: &str) -> String { + if let Some(uddg) = extract_query_param(href, "uddg") { + let decoded = percent_decode(&uddg); + if !decoded.is_empty() { + return decoded; + } + } + if href.starts_with("//") { + return format!("https:{href}"); + } + if href.starts_with('/') { + return format!("https://duckduckgo.com{href}"); + } + href.to_string() +} + +fn extract_query_param(url: &str, key: &str) -> Option { + let query_start = url.find('?')?; + let query = &url[query_start + 1..]; + for part in query.split('&') { + let mut pieces = part.splitn(2, '='); + let k = pieces.next()?; + let v = pieces.next()?; + if k == key { + return Some(v.to_string()); + } + } + None +} + +fn percent_decode(input: &str) -> String { + let mut out = String::new(); + let bytes = input.as_bytes(); + let mut idx = 0; + while idx < bytes.len() { + if bytes[idx] == b'%' && idx + 2 < bytes.len() { + if let Ok(hex) = std::str::from_utf8(&bytes[idx + 1..idx + 3]) { + if let Ok(val) = u8::from_str_radix(hex, 16) { + out.push(val as char); + idx += 3; + continue; + } + } + } + out.push(bytes[idx] as char); + idx += 1; + } + out +} + +fn url_encode(input: &str) -> String { + let mut encoded = String::new(); + for ch in input.bytes() { + match ch { + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'_' + | b'.' + | b'~' => encoded.push(ch as char), + b' ' => encoded.push('+'), + _ => encoded.push_str(&format!("%{ch:02X}")), + } + } + encoded +} + +// === Tests === + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn html_link_parsing_extracts_links() { + let html = r#" + +

Hello Example world.

+ + "#; + let (lines, links, title) = parse_html(html, "https://example.com"); + assert!(title.is_none()); + assert_eq!(links.len(), 1); + assert_eq!(links[0].url, "https://example.com"); + assert!(lines.iter().any(|line| line.contains("Example"))); + } + + #[test] + fn wrap_line_splits_long_lines() { + let line = "This is a long line that should wrap cleanly at word boundaries"; + let wrapped = wrap_line(line, 20); + assert!(wrapped.len() > 1); + assert!(wrapped.iter().all(|l| l.len() <= 20)); + } +} diff --git a/src/tui/app.rs b/src/tui/app.rs index fc0b2122..0f41152c 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -407,8 +407,11 @@ impl App { let history_len = history.len() as u64; + let agents_skills_dir = workspace.join(".agents").join("skills"); let local_skills_dir = workspace.join("skills"); - let skills_dir = if local_skills_dir.exists() { + let skills_dir = if agents_skills_dir.exists() { + agents_skills_dir + } else if local_skills_dir.exists() { local_skills_dir } else { global_skills_dir diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 8a649360..c864c9d8 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -18,6 +18,7 @@ pub mod session_picker; pub mod streaming; pub mod transcript; pub mod ui; +pub mod user_input; pub mod views; pub mod widgets; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index d4f36333..a859c3d5 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -51,6 +51,7 @@ use crate::tui::paste_burst::CharDecision; use crate::tui::scrolling::{ScrollDirection, TranscriptScroll}; use crate::tui::selection::TranscriptSelectionPoint; use crate::tui::session_picker::SessionPickerView; +use crate::tui::user_input::UserInputView; use crate::utils::estimate_message_chars; use super::app::{App, AppAction, AppMode, OnboardingState, QueuedMessage, TuiOptions}; @@ -590,6 +591,12 @@ async fn run_event_loop( }); } } + EngineEvent::UserInputRequired { id, request } => { + app.view_stack.push(UserInputView::new(id.clone(), request)); + app.add_message(HistoryCell::System { + content: "User input requested".to_string(), + }); + } EngineEvent::ToolCallProgress { id, output } => { app.status_message = Some(format!("Tool {id}: {}", summarize_tool_output(&output))); @@ -1949,6 +1956,15 @@ async fn handle_view_events(app: &mut App, engine_handle: &EngineHandle, events: } } } + ViewEvent::UserInputSubmitted { tool_id, response } => { + let _ = engine_handle.submit_user_input(tool_id, response).await; + } + ViewEvent::UserInputCancelled { tool_id } => { + let _ = engine_handle.cancel_user_input(tool_id).await; + app.add_message(HistoryCell::System { + content: "User input cancelled".to_string(), + }); + } ViewEvent::SessionSelected { session_id } => { let manager = match SessionManager::default_location() { Ok(manager) => manager, @@ -3127,10 +3143,19 @@ fn is_view_image_tool(name: &str) -> bool { } fn is_web_search_tool(name: &str) -> bool { - matches!(name, "web_search" | "search_web" | "search") || name.ends_with("_web_search") + matches!(name, "web_search" | "search_web" | "search" | "web.run") + || name.ends_with("_web_search") } fn web_search_query(input: &serde_json::Value) -> String { + if let Some(searches) = input.get("search_query").and_then(|v| v.as_array()) { + if let Some(first) = searches.first() { + if let Some(q) = first.get("q").and_then(|v| v.as_str()) { + return q.to_string(); + } + } + } + input .get("query") .or_else(|| input.get("q")) diff --git a/src/tui/user_input.rs b/src/tui/user_input.rs new file mode 100644 index 00000000..4a09edf5 --- /dev/null +++ b/src/tui/user_input.rs @@ -0,0 +1,263 @@ +//! Modal for request_user_input tool prompts. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Alignment, Rect}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; + +use crate::palette; +use crate::tools::user_input::{ + UserInputAnswer, UserInputQuestion, UserInputRequest, UserInputResponse, +}; +use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InputMode { + Selecting, + OtherInput, +} + +#[derive(Debug, Clone)] +pub struct UserInputView { + tool_id: String, + request: UserInputRequest, + question_index: usize, + selected: usize, + mode: InputMode, + other_input: String, + answers: Vec, +} + +impl UserInputView { + pub fn new(tool_id: impl Into, request: UserInputRequest) -> Self { + Self { + tool_id: tool_id.into(), + request, + question_index: 0, + selected: 0, + mode: InputMode::Selecting, + other_input: String::new(), + answers: Vec::new(), + } + } + + fn current_question(&self) -> &UserInputQuestion { + &self.request.questions[self.question_index] + } + + fn option_count(&self) -> usize { + self.current_question().options.len() + 1 + } + + fn is_other_selected(&self) -> bool { + self.selected + 1 == self.option_count() + } + + fn advance_question(&mut self, answer: UserInputAnswer) -> ViewAction { + self.answers.push(answer); + if self.question_index + 1 >= self.request.questions.len() { + let response = UserInputResponse { + answers: self.answers.clone(), + }; + return ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted { + tool_id: self.tool_id.clone(), + response, + }); + } + self.question_index += 1; + self.selected = 0; + self.mode = InputMode::Selecting; + self.other_input.clear(); + ViewAction::None + } + + fn handle_selecting_key(&mut self, key: KeyEvent) -> ViewAction { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1); + ViewAction::None + } + KeyCode::Down | KeyCode::Char('j') => { + self.selected = (self.selected + 1).min(self.option_count().saturating_sub(1)); + ViewAction::None + } + KeyCode::Enter => { + if self.is_other_selected() { + self.mode = InputMode::OtherInput; + self.other_input.clear(); + ViewAction::None + } else { + let question = self.current_question(); + let option = &question.options[self.selected]; + let answer = UserInputAnswer { + id: question.id.clone(), + label: option.label.clone(), + value: option.label.clone(), + }; + self.advance_question(answer) + } + } + KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::UserInputCancelled { + tool_id: self.tool_id.clone(), + }), + _ => ViewAction::None, + } + } + + fn handle_other_input_key(&mut self, key: KeyEvent) -> ViewAction { + match key.code { + KeyCode::Esc => { + self.mode = InputMode::Selecting; + self.other_input.clear(); + ViewAction::None + } + KeyCode::Enter => { + let question = self.current_question(); + let answer = UserInputAnswer { + id: question.id.clone(), + label: "Other".to_string(), + value: self.other_input.trim().to_string(), + }; + self.advance_question(answer) + } + KeyCode::Backspace => { + self.other_input.pop(); + ViewAction::None + } + KeyCode::Char(ch) => { + if !ch.is_control() { + self.other_input.push(ch); + } + ViewAction::None + } + _ => ViewAction::None, + } + } +} + +impl ModalView for UserInputView { + fn kind(&self) -> ModalKind { + ModalKind::UserInput + } + + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + match self.mode { + InputMode::Selecting => self.handle_selecting_key(key), + InputMode::OtherInput => self.handle_other_input_key(key), + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + let question = self.current_question(); + let total = self.request.questions.len(); + let header = format!( + " {} ({}/{}) ", + question.header, + self.question_index + 1, + total + ); + + let mut lines: Vec = Vec::new(); + lines.push(Line::from(vec![Span::styled( + question.question.clone(), + Style::default().fg(palette::TEXT_PRIMARY).bold(), + )])); + lines.push(Line::from("")); + + for (idx, option) in question.options.iter().enumerate() { + let selected = self.selected == idx; + let prefix = if selected { ">" } else { " " }; + let style = if selected { + Style::default().fg(palette::DEEPSEEK_SKY).bold() + } else { + Style::default().fg(palette::TEXT_PRIMARY) + }; + lines.push(Line::from(vec![ + Span::raw(format!("{prefix} ")), + Span::styled(option.label.clone(), style), + Span::raw(" - "), + Span::styled(option.description.clone(), Style::default().fg(palette::TEXT_MUTED)), + ])); + } + + let other_index = question.options.len(); + let other_selected = self.selected == other_index; + let other_style = if other_selected { + Style::default().fg(palette::DEEPSEEK_SKY).bold() + } else { + Style::default().fg(palette::TEXT_PRIMARY) + }; + lines.push(Line::from(vec![ + Span::raw(if other_selected { "> " } else { " " }), + Span::styled("Other", other_style), + Span::raw(" - "), + Span::styled( + "Provide a custom response", + Style::default().fg(palette::TEXT_MUTED), + ), + ])); + + if self.mode == InputMode::OtherInput { + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("Other:", Style::default().fg(palette::TEXT_PRIMARY)), + Span::raw(" "), + Span::styled( + if self.other_input.is_empty() { + "(type your response)".to_string() + } else { + self.other_input.clone() + }, + Style::default().fg(palette::DEEPSEEK_BLUE), + ), + ])); + } + + lines.push(Line::from("")); + let hint = if self.mode == InputMode::OtherInput { + "Enter=submit, Esc=back" + } else { + "Up/Down=select, Enter=confirm, Esc=cancel" + }; + lines.push(Line::from(Span::styled( + hint, + Style::default().fg(palette::TEXT_MUTED), + ))); + + let paragraph = Paragraph::new(lines) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) + .block( + Block::default() + .title(Line::from(vec![Span::styled( + header, + Style::default().fg(palette::DEEPSEEK_BLUE).bold(), + )])) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::DEEPSEEK_SKY)), + ); + + let popup_area = centered_rect(80, 60, area); + paragraph.render(popup_area, buf); + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1]); + horizontal[1] +} diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs index 5b2c75c4..d10d8c3c 100644 --- a/src/tui/views/mod.rs +++ b/src/tui/views/mod.rs @@ -4,12 +4,14 @@ use std::fmt; use crate::palette; use crate::tools::subagent::{SubAgentResult, SubAgentStatus, SubAgentType}; +use crate::tools::UserInputResponse; use crate::tui::approval::{ElevationOption, ReviewDecision}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModalKind { Approval, Elevation, + UserInput, Help, SubAgents, Pager, @@ -29,6 +31,13 @@ pub enum ViewEvent { tool_name: String, option: ElevationOption, }, + UserInputSubmitted { + tool_id: String, + response: UserInputResponse, + }, + UserInputCancelled { + tool_id: String, + }, SubAgentsRefresh, SessionSelected { session_id: String, @@ -308,7 +317,37 @@ impl ModalView for HelpView { Style::default().fg(palette::DEEPSEEK_SKY).bold(), )])); help_lines.push(Line::from( - " web_search - Search the web (DuckDuckGo; MCP optional)", + " web.run - Browse the web (search/open/click/find/screenshot)", + )); + help_lines.push(Line::from( + " web_search - Quick web search (DuckDuckGo; MCP optional)", + )); + help_lines.push(Line::from( + " request_user_input - Ask the user to choose from short prompts", + )); + help_lines.push(Line::from( + " multi_tool_use.parallel - Execute multiple tools in parallel", + )); + help_lines.push(Line::from( + " weather - Daily forecast for a location", + )); + help_lines.push(Line::from( + " finance - Stock/crypto price lookup", + )); + help_lines.push(Line::from( + " sports - League schedules/standings", + )); + help_lines.push(Line::from( + " time - Current time for UTC offsets", + )); + help_lines.push(Line::from( + " calculator - Evaluate arithmetic expressions", + )); + help_lines.push(Line::from( + " list_mcp_resources - List MCP resources (optionally by server)", + )); + help_lines.push(Line::from( + " list_mcp_resource_templates - List MCP resource templates", )); help_lines.push(Line::from(" mcp_* - Tools exposed by MCP servers")); help_lines.push(Line::from(""));