Update tool parity and skills docs
This commit is contained in:
Generated
+390
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,9 @@ Unofficial terminal UI (TUI) + CLI for the [DeepSeek platform](https://platform.
|
||||
- **Shell execution**: Run commands with timeout support, background execution with task management
|
||||
- **Task management**: Todo lists, implementation plans, persistent notes
|
||||
- **Sub-agent system**: Spawn, coordinate, and cancel background agents (including swarms)
|
||||
- **Web search**: Integrated web search with DuckDuckGo
|
||||
- **User input prompts**: Ask structured, multiple-choice questions during tool flows
|
||||
- **Web browsing**: `web.run` search/open/click/find/screenshot with citation-ready sources
|
||||
- **Structured data tools**: weather, finance, sports, time, calculator
|
||||
- **Multi‑model support** – DeepSeek‑Reasoner, DeepSeek‑Chat, and other DeepSeek models
|
||||
- **Context‑aware** – loads project‑specific instructions from `AGENTS.md`
|
||||
- **Session management** – resume, fork, and search past conversations
|
||||
@@ -124,7 +126,17 @@ DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categori
|
||||
- **`edit_file`** – Search and replace text in files
|
||||
- **`apply_patch`** – Apply unified diff patches with fuzzy matching
|
||||
- **`grep_files`** – Search files by regex pattern with context lines
|
||||
- **`web_search`** – Search the web and return concise results
|
||||
- **`web.run`** – Browse the web (search/open/click/find/screenshot) with ref_ids for citations
|
||||
- **`web_search`** – Quick web search (fallback when citations are not needed)
|
||||
- **`request_user_input`** – Ask the user short multiple-choice questions
|
||||
- **`multi_tool_use.parallel`** – Execute multiple read-only tools in parallel
|
||||
|
||||
#### Structured Data
|
||||
- **`weather`** – Daily weather forecast for a location
|
||||
- **`finance`** – Latest price for a stock, fund, index, or cryptocurrency
|
||||
- **`sports`** – Schedules or standings for a league
|
||||
- **`time`** – Current time for a UTC offset
|
||||
- **`calculator`** – Evaluate arithmetic expressions
|
||||
|
||||
#### Shell Execution
|
||||
- **`exec_shell`** – Run shell commands with timeout support
|
||||
@@ -146,8 +158,9 @@ DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categori
|
||||
|
||||
- **Workspace boundary**: File tools are restricted to `--workspace` unless you enable `/trust` (YOLO enables trust automatically).
|
||||
- **Approvals**: The TUI requests approval depending on mode and tool category (file writes, shell).
|
||||
- **Web search**: `web_search` uses DuckDuckGo HTML results and is auto‑approved.
|
||||
- **Skills**: Reusable workflows stored as `SKILL.md` directories (default: `~/.deepseek/skills`, or `./skills` per workspace). Use `/skills` and `/skill <name>`. Bootstrap with `deepseek setup --skills` (add `--local` for `./skills`).
|
||||
- **Web browsing**: `web.run` uses DuckDuckGo HTML results and is auto‑approved.
|
||||
- **Web search**: `web_search` is a quick fallback when citations are not needed.
|
||||
- **Skills**: Reusable workflows stored as `SKILL.md` directories. The resolved skills dir prefers workspace-local `.agents/skills`, then `./skills`, then `~/.deepseek/skills`. Use `/skills` and `/skill <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 & Large‑scale Memory)
|
||||
@@ -248,8 +261,9 @@ Run `deepseek sessions` and try `deepseek --resume latest`.
|
||||
|
||||
### Skills missing
|
||||
Run `deepseek setup --skills` to create a global skills directory, or add `--local`
|
||||
to create `./skills` for the current workspace. Then run `deepseek doctor` to see
|
||||
which skills directory is selected.
|
||||
to create `./skills` for the current workspace. If you want the preferred
|
||||
workspace-local path, create `.agents/skills` manually. Then run `deepseek doctor`
|
||||
to see which skills directory is selected.
|
||||
|
||||
### MCP tools missing
|
||||
Run `deepseek mcp init` (or `deepseek setup --mcp`), then restart. `deepseek doctor`
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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(""));
|
||||
|
||||
Reference in New Issue
Block a user