Update tool parity and skills docs

This commit is contained in:
Hunter Bown
2026-02-03 17:34:55 -06:00
parent 62d2877b3b
commit 325aaefc00
33 changed files with 5293 additions and 267 deletions
Generated
+390
View File
@@ -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"
+3
View File
@@ -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"
+68 -165
View File
@@ -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
+20 -6
View File
@@ -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
- **Multimodel support** DeepSeekReasoner, DeepSeekChat, and other DeepSeek models
- **Contextaware** loads projectspecific 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 autoapproved.
- **Skills**: Reusable workflows stored as `SKILL.md` directories (default: `~/.deepseek/skills`, or `./skills` per workspace). Use `/skills` and `/skill <name>`. Bootstrap with `deepseek setup --skills` (add `--local` for `./skills`).
- **Web browsing**: `web.run` uses DuckDuckGo HTML results and is autoapproved.
- **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 <name>`. 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 & Largescale 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`
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+9
View File
@@ -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
+319 -4
View File
@@ -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<ApprovalDecision>,
/// Send user input responses to the engine
tx_user_input: mpsc::Sender<UserInputDecision>,
}
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<String>,
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<String>) -> 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<String>,
session: Session,
subagent_manager: SharedSubAgentManager,
shell_manager: SharedShellManager,
mcp_pool: Option<Arc<AsyncMutex<McpPool>>>,
rx_op: mpsc::Receiver<Op>,
rx_approval: mpsc::Receiver<ApprovalDecision>,
rx_user_input: mpsc::Receiver<UserInputDecision>,
tx_event: mpsc::Sender<Event>,
cancel_token: CancellationToken,
tool_exec_lock: Arc<RwLock<()>>,
@@ -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<String>,
}
#[derive(Debug, serde::Serialize)]
struct ParallelToolResult {
results: Vec<ParallelToolResultEntry>,
}
// 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] = [
"<invoke ",
"<function_calls>",
];
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]",
"</deepseek:tool_call>",
@@ -372,6 +429,52 @@ fn extract_balanced_segment(text: &str, open: char, close: char) -> Option<Strin
end.map(|end_idx| text[start..end_idx].to_string())
}
fn normalize_parallel_tool_name(raw: &str) -> 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<Vec<(String, serde_json::Value)>, 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<RwLock<()>>,
) -> Result<ToolResult, ToolError> {
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<RwLock<()>>,
@@ -1006,6 +1215,48 @@ impl Engine {
}
}
async fn await_user_input(
&mut self,
tool_id: &str,
request: UserInputRequest,
) -> Result<UserInputResponse, ToolError> {
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<Result<ToolResult, ToolError>>,
+7
View File
@@ -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,
+23 -3
View File
@@ -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
+209
View File
@@ -121,6 +121,18 @@ pub struct McpResource {
pub mime_type: Option<String>,
}
/// 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<String>,
#[serde(rename = "mimeType", default)]
pub mime_type: Option<String>,
}
/// Prompt discovered from an MCP server
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpPrompt {
@@ -322,6 +334,7 @@ pub struct McpConnection {
transport: Box<dyn McpTransport>,
tools: Vec<McpTool>,
resources: Vec<McpResource>,
resource_templates: Vec<McpResourceTemplate>,
prompts: Vec<McpPrompt>,
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<String>) -> Result<Vec<serde_json::Value>> {
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<String>,
) -> Result<Vec<serde_json::Value>> {
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<serde_json::Value> {
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"));
}
+20 -2
View File
@@ -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.
+4 -1
View File
@@ -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.
+15 -1
View File
@@ -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.
+12 -1
View File
@@ -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.
+90
View File
@@ -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<ToolCapability> {
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<ToolResult, ToolError> {
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");
}
}
+354
View File
@@ -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<f64>,
currency: Option<String>,
as_of: Option<String>,
details: Value,
}
#[derive(Debug, Clone, Serialize)]
struct FinanceResponse {
results: Vec<FinanceResult>,
}
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<ToolCapability> {
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<ToolResult, ToolError> {
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<Vec<FinanceRequest>, 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<FinanceResult, ToolError> {
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<FinanceResult, ToolError> {
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<f64> {
input.parse::<f64>().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
}
+23 -1
View File
@@ -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};
+54
View File
@@ -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<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, _input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
Err(ToolError::execution_failed(
"multi_tool_use.parallel must be handled by the engine",
))
}
}
+41 -2
View File
@@ -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)))
}
+745 -68
View File
@@ -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<dyn portable_pty::Child + Send>),
}
#[derive(Clone, Copy, Debug)]
struct ShellExitStatus {
code: Option<i32>,
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<Option<ShellExitStatus>> {
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<ShellExitStatus> {
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<dyn Write + Send>),
}
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<R: Read + Send + 'static>(
mut reader: R,
buffer: Arc<Mutex<Vec<u8>>>,
) -> 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<i32>,
pub stdout: String,
pub stderr: String,
pub started_at: Instant,
pub sandbox_type: SandboxType,
child: Option<Child>,
stdout_thread: Option<std::thread::JoinHandle<Vec<u8>>>,
stderr_thread: Option<std::thread::JoinHandle<Vec<u8>>>,
stdout_buffer: Arc<Mutex<Vec<u8>>>,
stderr_buffer: Option<Arc<Mutex<Vec<u8>>>>,
stdout_cursor: usize,
stderr_cursor: usize,
stdin: Option<StdinWriter>,
child: Option<ShellChild>,
stdout_thread: Option<std::thread::JoinHandle<()>>,
stderr_thread: Option<std::thread::JoinHandle<()>>,
}
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<ExecutionSandboxPolicy>,
) -> Result<ShellResult> {
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<ExecutionSandboxPolicy>,
) -> Result<ShellResult> {
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<ShellResult> {
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<ShellResult> {
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<ShellDeltaResult> {
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<ShellResult> {
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<Mutex<Vec<u8>>>,
cursor: &mut usize,
) -> (Vec<u8>, 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<ExecPolicyDecision> = 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<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(
&self,
input: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
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<ToolCapability> {
vec![ToolCapability::ExecutesCode]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(
&self,
input: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
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);
+20 -2
View File
@@ -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<PathBuf>) -> 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<PathBuf>,
mcp_config_path: impl Into<PathBuf>,
) -> 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<PathBuf>,
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
+418
View File
@@ -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<String>,
}
#[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<SportsGame>,
}
#[derive(Debug, Clone, Serialize)]
struct SportsStandingEntry {
name: String,
abbreviation: String,
stats: Value,
}
#[derive(Debug, Clone, Serialize)]
struct SportsStandingsResponse {
league: String,
entries: Vec<SportsStandingEntry>,
}
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<ToolCapability> {
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<ToolResult, ToolError> {
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<SportsScheduleResponse, ToolError> {
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<SportsGameTeam>, Option<SportsGameTeam>) {
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<SportsStandingsResponse, ToolError> {
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<Value> {
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<Option<String>, 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()))
}
+290 -5
View File
@@ -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<String>,
input_tx: Option<mpsc::UnboundedSender<SubAgentInput>>,
task_handle: Option<JoinHandle<()>>,
}
impl SubAgent {
/// Create a new sub-agent.
fn new(agent_type: SubAgentType, prompt: String, allowed_tools: Vec<String>) -> Self {
fn new(
agent_type: SubAgentType,
prompt: String,
allowed_tools: Vec<String>,
input_tx: mpsc::UnboundedSender<SubAgentInput>,
) -> 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<SubAgentResult> {
@@ -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<ToolCapability> {
vec![]
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
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<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let timeout_ms = optional_u64(&input, "timeout_ms", 30_000).clamp(1000, 300_000);
let mut ids: Vec<String> = 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::<Result<Vec<_>, _>>()?
};
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<String>,
started_at: Instant,
max_steps: u32,
input_rx: mpsc::UnboundedReceiver<SubAgentInput>,
}
#[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<String>,
started_at: Instant,
max_steps: u32,
mut input_rx: mpsc::UnboundedReceiver<SubAgentInput>,
) -> Result<SubAgentResult> {
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<String> = None;
let mut pending_inputs: VecDeque<SubAgentInput> = 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<ContentBlock> = 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);
+153
View File
@@ -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<TimeResult>,
}
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<ToolCapability> {
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<ToolResult, ToolError> {
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<FixedOffset> = 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<Vec<TimeRequest>, 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<FixedOffset, ToolError> {
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);
}
}
+256
View File
@@ -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<UserInputOption>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInputRequest {
pub questions: Vec<UserInputQuestion>,
}
impl UserInputRequest {
pub fn from_value(value: &Value) -> Result<Self, ToolError> {
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<UserInputAnswer>,
}
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<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, _input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
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());
}
}
+344
View File
@@ -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<WeatherDay>,
}
#[derive(Debug, Clone, Serialize)]
struct WeatherResponse {
results: Vec<WeatherResult>,
}
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<ToolCapability> {
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<ToolResult, ToolError> {
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<Vec<WeatherRequest>, 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<GeoResult, ToolError> {
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<WeatherDay>,
}
async fn fetch_forecast(
client: &reqwest::Client,
geo: &GeoResult,
start: &str,
duration: u64,
) -> Result<ForecastResult, ToolError> {
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
}
+1069
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -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
+1
View File
@@ -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;
+26 -1
View File
@@ -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"))
+263
View File
@@ -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<UserInputAnswer>,
}
impl UserInputView {
pub fn new(tool_id: impl Into<String>, 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<Line> = 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]
}
+40 -1
View File
@@ -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(""));