Compare commits
44 Commits
v0.6.0
...
e3815af69b
| Author | SHA1 | Date | |
|---|---|---|---|
|
e3815af69b
|
|||
|
bbcae1fc2b
|
|||
|
3ff27a7935
|
|||
|
373d80121a
|
|||
|
3299a4699e
|
|||
|
d4dbda1e89
|
|||
|
e77fa6ef42
|
|||
|
241dda24f0
|
|||
|
e5668e4495
|
|||
|
4a01e9a66c
|
|||
|
530000bc2f
|
|||
|
f2e8f3ab59
|
|||
|
2f33b6631e
|
|||
|
8c288195a0
|
|||
|
e6a5e67a8e
|
|||
|
6ae474c79e
|
|||
|
8e0b07c9fb
|
|||
|
69589bd5e5
|
|||
|
587df087ed
|
|||
|
ee100eef96
|
|||
|
14969e35fa
|
|||
|
b927e2a200
|
|||
|
6ce69ee989
|
|||
|
dc6d736df3
|
|||
|
2a79616f8b
|
|||
|
eb6a02f947
|
|||
|
00939e4634
|
|||
|
6ebd32d47c
|
|||
|
73c4449e7f
|
|||
|
7143b50d98
|
|||
|
de38e663a0
|
|||
|
10de6025b5
|
|||
|
0d2292bff6
|
|||
|
eb38ca0bbb
|
|||
|
1931331163
|
|||
|
218750cc1e
|
|||
|
a10b23dbc1
|
|||
|
19d2340489
|
|||
|
4ece3d3df1
|
|||
|
6d5cbfa56d
|
|||
|
7e097e0465
|
|||
|
b2d70a3fd3
|
|||
|
3183fedca9
|
|||
|
33c6f2c4e3
|
Generated
+80
-79
@@ -298,7 +298,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"hex",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"sha1",
|
||||
"time",
|
||||
"tokio",
|
||||
@@ -371,7 +371,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"bytes-utils",
|
||||
"fastrand",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -399,7 +399,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
@@ -424,7 +424,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
@@ -449,7 +449,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
@@ -475,7 +475,7 @@ dependencies = [
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
@@ -495,7 +495,7 @@ dependencies = [
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"http 0.2.12",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"percent-encoding",
|
||||
"sha2 0.11.0",
|
||||
"time",
|
||||
@@ -536,7 +536,7 @@ dependencies = [
|
||||
"bytes-utils",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"percent-encoding",
|
||||
@@ -557,7 +557,7 @@ dependencies = [
|
||||
"h2 0.3.27",
|
||||
"h2 0.4.14",
|
||||
"http 0.2.12",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 0.4.6",
|
||||
"hyper 0.14.32",
|
||||
"hyper 1.10.1",
|
||||
@@ -621,7 +621,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 0.4.6",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
@@ -642,7 +642,7 @@ dependencies = [
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
"http 0.2.12",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -668,7 +668,7 @@ checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -682,7 +682,7 @@ dependencies = [
|
||||
"bytes-utils",
|
||||
"futures-core",
|
||||
"http 0.2.12",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 0.4.6",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
@@ -730,7 +730,7 @@ dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"itoa",
|
||||
@@ -754,7 +754,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
@@ -924,9 +924,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.12.1"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -951,9 +951,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.0"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
@@ -1060,9 +1060,9 @@ checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.63"
|
||||
version = "1.2.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -1411,6 +1411,7 @@ dependencies = [
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
"aws-smithy-eventstream",
|
||||
"aws-smithy-types",
|
||||
"base64",
|
||||
"bincode 2.0.1",
|
||||
"bitflags",
|
||||
@@ -1433,7 +1434,7 @@ dependencies = [
|
||||
"hmac 0.12.1",
|
||||
"hnsw_rs",
|
||||
"html_to_markdown",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"indexmap 2.14.0",
|
||||
"indoc",
|
||||
"inquire",
|
||||
@@ -1466,6 +1467,7 @@ dependencies = [
|
||||
"sys-locale",
|
||||
"terminal-colorsaurus",
|
||||
"textwrap",
|
||||
"time",
|
||||
"tokio",
|
||||
"tree-sitter",
|
||||
"tree-sitter-python",
|
||||
@@ -1837,7 +1839,7 @@ version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
@@ -2507,7 +2509,7 @@ dependencies = [
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"indexmap 2.14.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
@@ -2659,9 +2661,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.1"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
@@ -2685,7 +2687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2696,7 +2698,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"pin-project-lite",
|
||||
]
|
||||
@@ -2779,7 +2781,7 @@ dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2 0.4.14",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
@@ -2811,7 +2813,7 @@ version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"hyper 1.10.1",
|
||||
"hyper-util",
|
||||
"rustls 0.23.40",
|
||||
@@ -2860,7 +2862,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"hyper 1.10.1",
|
||||
"ipnet",
|
||||
@@ -3240,13 +3242,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.99"
|
||||
version = "0.3.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
|
||||
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -3477,9 +3478,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
@@ -3544,9 +3545,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mock_instant"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6"
|
||||
checksum = "9bb517913cfcfb9eeda59f36020269075a152701a01606c612f547e4890be399"
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
@@ -3956,9 +3957,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.6.0+3.6.2"
|
||||
version = "300.6.1+3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4"
|
||||
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
@@ -4355,9 +4356,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.3"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
|
||||
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
@@ -4365,9 +4366,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.14.3"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
|
||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -4378,9 +4379,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prost-types"
|
||||
version = "0.14.3"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
|
||||
checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
|
||||
dependencies = [
|
||||
"prost",
|
||||
]
|
||||
@@ -4626,9 +4627,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
version = "1.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -4655,9 +4656,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
@@ -4669,7 +4670,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.10.1",
|
||||
@@ -4715,7 +4716,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.4.14",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.10.1",
|
||||
@@ -4783,7 +4784,7 @@ dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"futures",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"process-wrap",
|
||||
@@ -5197,7 +5198,7 @@ checksum = "2e79722b5a505d4ddc77527455a97244e9e8c4c07533ff44cf4421cce7bb6d17"
|
||||
dependencies = [
|
||||
"either",
|
||||
"flate2",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"indicatif",
|
||||
"log",
|
||||
"quick-xml 0.38.4",
|
||||
@@ -5554,9 +5555,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "smawk"
|
||||
@@ -6121,7 +6122,7 @@ dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"h2 0.4.14",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.10.1",
|
||||
@@ -6182,7 +6183,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
@@ -6468,7 +6469,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
@@ -6517,9 +6518,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.2"
|
||||
version = "1.23.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
|
||||
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -6628,9 +6629,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.122"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
|
||||
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -6641,9 +6642,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.72"
|
||||
version = "0.4.73"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
|
||||
checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -6651,9 +6652,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.122"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
|
||||
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -6661,9 +6662,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.122"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
|
||||
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -6674,9 +6675,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.122"
|
||||
version = "0.2.123"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
|
||||
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -6813,9 +6814,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.99"
|
||||
version = "0.3.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
|
||||
checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -6851,9 +6852,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "8.0.2"
|
||||
version = "8.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459"
|
||||
checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -7435,18 +7436,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.50"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
||||
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.50"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
||||
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -58,6 +58,8 @@ http = "1.1.0"
|
||||
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||
hmac = "0.12.1"
|
||||
aws-smithy-eventstream = "0.60.4"
|
||||
aws-smithy-types = "=1.4.9"
|
||||
time = "=0.3.47"
|
||||
urlencoding = "2.1.3"
|
||||
json-patch = { version = "4.0.0", default-features = false }
|
||||
bitflags = "2.5.0"
|
||||
|
||||
@@ -25,6 +25,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
|
||||
* [REPL](https://github.com/Dark-Alex-17/coyote/wiki/REPL): Interactive Read-Eval-Print Loop for conversational interactions with LLMs and Coyote.
|
||||
* [Custom REPL Prompt](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt): Customize the REPL prompt to provide useful contextual information.
|
||||
* [Vault](https://github.com/Dark-Alex-17/coyote/wiki/Vault): Securely store and manage sensitive information such as API keys and credentials.
|
||||
* [Sandboxes](https://github.com/Dark-Alex-17/coyote/wiki/Sandboxes): Launch Coyote inside an isolated [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) with one command. Host config and vault credentials are projected in automatically; everything else is delegated to the `sbx` CLI.
|
||||
* [Shell Integrations](https://github.com/Dark-Alex-17/coyote/wiki/Shell-Integrations): Seamlessly integrate Coyote with your shell environment for enhanced command-line assistance.
|
||||
* [Function Calling](https://github.com/Dark-Alex-17/coyote/wiki/Tools): Leverage function calling capabilities to extend Coyote's functionality with custom tools
|
||||
* [Creating Custom Tools](https://github.com/Dark-Alex-17/coyote/wiki/Custom-Tools): You can create your own custom tools to enhance Coyote's capabilities.
|
||||
@@ -36,6 +37,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
|
||||
* [Macros](https://github.com/Dark-Alex-17/coyote/wiki/Macros): Automate repetitive tasks and workflows with Coyote "scripts" (macros).
|
||||
* [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation.
|
||||
* [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions.
|
||||
* [Memory](https://github.com/Dark-Alex-17/coyote/wiki/Memory): Persistent file-based memory that survives across sessions. Bootstrap with `coyote --init-memory [global|workspace]`.
|
||||
* [Roles](https://github.com/Dark-Alex-17/coyote/wiki/Roles): Customize model behavior for specific tasks or domains.
|
||||
* [Skills](https://github.com/Dark-Alex-17/coyote/wiki/Skills): Modular knowledge or capability packs the LLM can load and unload mid-conversation. Multiple skills compose; instructions stack, tools and MCPs union.
|
||||
* [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
name: built-in-tools
|
||||
description: >
|
||||
Installs binaries and allows network domains required by Coyote's built-in
|
||||
global tools and the default MCP server set. Auto-applied by Coyote's sbx
|
||||
mixin discovery when running `coyote --sandbox`.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
# fetch_url_via_jina + jina reader fallback
|
||||
- "r.jina.ai:443"
|
||||
# get_current_weather (.sh, .py, .ts)
|
||||
- "wttr.in:443"
|
||||
# search_arxiv (the .sh tool still uses http://, so :80 is required until fixed)
|
||||
- "export.arxiv.org:443"
|
||||
- "export.arxiv.org:80"
|
||||
# search_arxiv + search_wikipedia may follow DOI redirects
|
||||
- "doi.org:443"
|
||||
# search_wikipedia
|
||||
- "en.wikipedia.org:443"
|
||||
# search_wolframalpha
|
||||
- "api.wolframalpha.com:443"
|
||||
# web_search_perplexity
|
||||
- "api.perplexity.ai:443"
|
||||
# web_search_tavily
|
||||
- "api.tavily.com:443"
|
||||
# send_twilio
|
||||
- "api.twilio.com:443"
|
||||
# MCP: github (built-in mcp.json: api.githubcopilot.com)
|
||||
- "api.githubcopilot.com:443"
|
||||
# MCP: atlassian (built-in mcp.json: mcp-remote -> mcp.atlassian.com)
|
||||
- "mcp.atlassian.com:443"
|
||||
# MCP: ddg-search (built-in mcp.json: uvx duckduckgo-mcp-server)
|
||||
- "duckduckgo.com:443"
|
||||
- "html.duckduckgo.com:443"
|
||||
- "lite.duckduckgo.com:443"
|
||||
# MCP: npx-based servers (mcp-remote) pull from npm
|
||||
- "registry.npmjs.org:443"
|
||||
# MCP: docker server may pull images from common registries
|
||||
- "ghcr.io:443"
|
||||
- "registry-1.docker.io:443"
|
||||
- "auth.docker.io:443"
|
||||
- "production.cloudflare.docker.com:443"
|
||||
@@ -5,6 +5,23 @@ set -e
|
||||
# PREFERRED way to modify a file. Prefer this over fs_write whenever the file already exists: it sends less data,
|
||||
# preserves unchanged content automatically, and is less prone to accidental data loss from full rewrites.
|
||||
# Use fs_write only when you are creating a new file or doing a complete rewrite where most of the content changes.
|
||||
#
|
||||
# CRITICAL — the patch is matched byte-for-byte. There is no fuzzy matching, no whitespace tolerance, and no context shift:
|
||||
# - Context lines (prefixed with a single space) and removed lines (prefixed with '-') must equal the file content exactly.
|
||||
# If unsure, fs_cat the file first and copy the bytes verbatim into your patch.
|
||||
# - JSON-escape the contents string ONCE. Each literal backslash in the file becomes \\ in the JSON contents string. So a
|
||||
# shell line containing s|\\"|"|g must appear in JSON as s|\\\\\"|\"|g — NOT s|\\\\\\\"|\\\"|g. Over-escaping backslashes
|
||||
# is the most common cause of "unable to apply patch" failures, especially in files with sed/jq/regex pipelines or
|
||||
# embedded Python with quoted strings.
|
||||
# - Hunks are applied in order; the first hunk that fails aborts the whole patch — later hunks are NOT attempted.
|
||||
# - If you've edited this file in earlier tool calls, fs_cat it again before composing the patch. A stale view of the file
|
||||
# produces context lines that no longer match.
|
||||
# - On failure the error message names the failing hunk and shows the expected-vs-actual line. Fix that specific line and
|
||||
# retry — do not blindly resend a near-identical patch.
|
||||
#
|
||||
# For files with heavy escaping (sed/jq/regex pipelines, shell with embedded heredocs, deeply quoted strings), prefer
|
||||
# fs_write over chained fs_patch hunks to replace the entire file with the full new contents (i.e. original content +
|
||||
# your changes).
|
||||
|
||||
# @option --path! The path of the file to apply the patch to
|
||||
# @option --contents! The patch to apply to the file
|
||||
|
||||
@@ -600,6 +600,14 @@ patch_file() {
|
||||
|
||||
for (i = 2; i <= hunkTotalOriginalLines[hunkIndex]; i++) {
|
||||
if (lines[nextLineIndex] != hunkOriginalLines[hunkIndex,i]) {
|
||||
if (i - 1 > bestPartialLen[hunkIndex]) {
|
||||
bestPartialLen[hunkIndex] = i - 1
|
||||
bestPartialAnchorLine[hunkIndex] = lineIndex
|
||||
bestPartialHunkPos[hunkIndex] = i
|
||||
bestPartialDivergeLine[hunkIndex] = nextLineIndex
|
||||
bestPartialExpected[hunkIndex] = hunkOriginalLines[hunkIndex,i]
|
||||
bestPartialActual[hunkIndex] = lines[nextLineIndex]
|
||||
}
|
||||
nextLineIndex = 0
|
||||
break
|
||||
}
|
||||
@@ -621,7 +629,32 @@ patch_file() {
|
||||
}
|
||||
|
||||
if (hunkIndex != totalHunks + 1) {
|
||||
failingHunk = hunkIndex
|
||||
print "error: unable to apply patch" > "/dev/stderr"
|
||||
print "" > "/dev/stderr"
|
||||
print "Hunk " failingHunk " of " totalHunks " did not match the file." > "/dev/stderr"
|
||||
|
||||
if (bestPartialLen[failingHunk] == 0) {
|
||||
print "" > "/dev/stderr"
|
||||
print "The first context/removed line of hunk " failingHunk " was not found anywhere in the file:" > "/dev/stderr"
|
||||
print " expected: " hunkOriginalLines[failingHunk, 1] > "/dev/stderr"
|
||||
} else {
|
||||
print "" > "/dev/stderr"
|
||||
print "Closest match: anchored at file line " bestPartialAnchorLine[failingHunk] ", matched " bestPartialLen[failingHunk] " of " hunkTotalOriginalLines[failingHunk] " original lines before diverging." > "/dev/stderr"
|
||||
print "" > "/dev/stderr"
|
||||
print "At file line " bestPartialDivergeLine[failingHunk] " (hunk original line " bestPartialHunkPos[failingHunk] "):" > "/dev/stderr"
|
||||
print " expected: " bestPartialExpected[failingHunk] > "/dev/stderr"
|
||||
print " actual: " bestPartialActual[failingHunk] > "/dev/stderr"
|
||||
}
|
||||
|
||||
print "" > "/dev/stderr"
|
||||
print "Lines must match byte-for-byte (no fuzzy matching). Check escaping, whitespace, and quoting." > "/dev/stderr"
|
||||
|
||||
if (failingHunk < totalHunks) {
|
||||
print "" > "/dev/stderr"
|
||||
print (totalHunks - failingHunk) " subsequent hunk(s) were not attempted (patcher aborts on first failure)." > "/dev/stderr"
|
||||
}
|
||||
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
# Docker sbx agent kit for Coyote
|
||||
#
|
||||
# Setup (paths use $HOME so commands work in bash/zsh/PowerShell/Git Bash):
|
||||
# sbx create --kit ./sbx-kit/ coyote --name testing .
|
||||
# sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/
|
||||
# sbx cp $HOME/.coyote_password testing:/home/agent/
|
||||
# sbx run testing --kit ./sbx-kit/
|
||||
schemaVersion: "1"
|
||||
kind: agent
|
||||
name: coyote
|
||||
displayName: Coyote
|
||||
description: >
|
||||
An all-in-one, batteries-included LLM CLI tool featuring Shell Assistant,
|
||||
CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros.
|
||||
|
||||
agent:
|
||||
image: "docker/sandbox-templates:shell-docker"
|
||||
aiFilename: COYOTE.md
|
||||
# persistence: persistent
|
||||
entrypoint:
|
||||
run: ["bash", "-lc", "exec /home/agent/.cargo/bin/coyote"]
|
||||
|
||||
network:
|
||||
# Proxy-managed LLM providers: the proxy substitutes `proxy-managed` for
|
||||
# the env var inside the sandbox and rewrites the auth header per
|
||||
# serviceAuth at request time. Multiple domains may map to one service
|
||||
# (e.g. jina) so they share a single credential.
|
||||
serviceDomains:
|
||||
api.openai.com: openai
|
||||
api.anthropic.com: anthropic
|
||||
generativelanguage.googleapis.com: gemini
|
||||
api.cohere.ai: cohere
|
||||
api.groq.com: groq
|
||||
openrouter.ai: openrouter
|
||||
api.ai21.com: ai21
|
||||
api.cloudflare.com: cloudflare
|
||||
api.deepinfra.com: deepinfra
|
||||
api.deepseek.com: deepseek
|
||||
api.mistral.ai: mistral
|
||||
api.perplexity.ai: perplexity
|
||||
api.voyageai.com: voyageai
|
||||
api.x.ai: xai
|
||||
api.jina.ai: jina
|
||||
r.jina.ai: jina
|
||||
qianfan.baidubce.com: ernie
|
||||
api.hunyuan.cloud.tencent.com: hunyuan
|
||||
api.minimax.chat: minimax
|
||||
api.moonshot.cn: moonshot
|
||||
dashscope.aliyuncs.com: qianwen
|
||||
open.bigmodel.cn: zhipuai
|
||||
serviceAuth:
|
||||
openai:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
anthropic:
|
||||
headerName: x-api-key
|
||||
valueFormat: "%s"
|
||||
gemini:
|
||||
headerName: x-goog-api-key
|
||||
valueFormat: "%s"
|
||||
cohere:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
groq:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
openrouter:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
ai21:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
cloudflare:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
deepinfra:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
deepseek:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
mistral:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
perplexity:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
voyageai:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
xai:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
jina:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
ernie:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
hunyuan:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
minimax:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
moonshot:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
qianwen:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
zhipuai:
|
||||
headerName: Authorization
|
||||
valueFormat: "Bearer %s"
|
||||
allowedDomains:
|
||||
# Coyote release + self-update + model-registry sync
|
||||
- "github.com:443"
|
||||
- "api.github.com:443"
|
||||
- "raw.githubusercontent.com:443"
|
||||
- "objects.githubusercontent.com:443"
|
||||
- "*.githubusercontent.com:443"
|
||||
# Coyote install paths (cargo install + uv + rustup + Python tool deps at runtime)
|
||||
- "crates.io:443"
|
||||
- "static.crates.io:443"
|
||||
- "pypi.org:443"
|
||||
- "files.pythonhosted.org:443"
|
||||
- "astral.sh:443"
|
||||
- "sh.rustup.rs:443"
|
||||
- "static.rust-lang.org:443"
|
||||
|
||||
# LLM model OAuth + API endpoints
|
||||
- "claude.ai:443"
|
||||
- "console.anthropic.com:443"
|
||||
- "accounts.google.com:443"
|
||||
# *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints
|
||||
# (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI.
|
||||
- "*.googleapis.com:443"
|
||||
|
||||
# Bedrock and GitHub Models use signed / GitHub-PAT auth that the proxy
|
||||
# cannot rewrite. Domains are allow-listed; credentials must be injected
|
||||
# separately (see README "Extending").
|
||||
- "*.amazonaws.com:443"
|
||||
- "models.inference.ai.azure.com:443"
|
||||
|
||||
credentials:
|
||||
sources:
|
||||
openai:
|
||||
env:
|
||||
- OPENAI_API_KEY
|
||||
anthropic:
|
||||
env:
|
||||
- ANTHROPIC_API_KEY
|
||||
gemini:
|
||||
env:
|
||||
- GEMINI_API_KEY
|
||||
- GOOGLE_API_KEY
|
||||
cohere:
|
||||
env:
|
||||
- COHERE_API_KEY
|
||||
groq:
|
||||
env:
|
||||
- GROQ_API_KEY
|
||||
openrouter:
|
||||
env:
|
||||
- OPENROUTER_API_KEY
|
||||
ai21:
|
||||
env:
|
||||
- AI21_API_KEY
|
||||
cloudflare:
|
||||
env:
|
||||
- CLOUDFLARE_API_KEY
|
||||
deepinfra:
|
||||
env:
|
||||
- DEEPINFRA_API_KEY
|
||||
deepseek:
|
||||
env:
|
||||
- DEEPSEEK_API_KEY
|
||||
mistral:
|
||||
env:
|
||||
- MISTRAL_API_KEY
|
||||
perplexity:
|
||||
env:
|
||||
- PERPLEXITY_API_KEY
|
||||
voyageai:
|
||||
env:
|
||||
- VOYAGE_API_KEY
|
||||
xai:
|
||||
env:
|
||||
- XAI_API_KEY
|
||||
jina:
|
||||
env:
|
||||
- JINA_API_KEY
|
||||
ernie:
|
||||
env:
|
||||
- ERNIE_API_KEY
|
||||
hunyuan:
|
||||
env:
|
||||
- HUNYUAN_API_KEY
|
||||
minimax:
|
||||
env:
|
||||
- MINIMAX_API_KEY
|
||||
moonshot:
|
||||
env:
|
||||
- MOONSHOT_API_KEY
|
||||
qianwen:
|
||||
env:
|
||||
- DASHSCOPE_API_KEY
|
||||
zhipuai:
|
||||
env:
|
||||
- ZHIPUAI_API_KEY
|
||||
|
||||
environment:
|
||||
variables:
|
||||
IS_SANDBOX: "1"
|
||||
COYOTE_LOG_LEVEL: INFO
|
||||
proxyManaged:
|
||||
- OPENAI_API_KEY
|
||||
- ANTHROPIC_API_KEY
|
||||
- GEMINI_API_KEY
|
||||
- GOOGLE_API_KEY
|
||||
- COHERE_API_KEY
|
||||
- GROQ_API_KEY
|
||||
- OPENROUTER_API_KEY
|
||||
- AI21_API_KEY
|
||||
- CLOUDFLARE_API_KEY
|
||||
- DEEPINFRA_API_KEY
|
||||
- DEEPSEEK_API_KEY
|
||||
- MISTRAL_API_KEY
|
||||
- PERPLEXITY_API_KEY
|
||||
- VOYAGE_API_KEY
|
||||
- XAI_API_KEY
|
||||
- JINA_API_KEY
|
||||
- ERNIE_API_KEY
|
||||
- HUNYUAN_API_KEY
|
||||
- MINIMAX_API_KEY
|
||||
- MOONSHOT_API_KEY
|
||||
- DASHSCOPE_API_KEY
|
||||
- ZHIPUAI_API_KEY
|
||||
|
||||
commands:
|
||||
install:
|
||||
- command: |
|
||||
sudo apt-get update &&
|
||||
sudo apt-get install -y \
|
||||
jq curl git \
|
||||
build-essential pkg-config \
|
||||
cmake \
|
||||
clang libclang-dev \
|
||||
musl-tools \
|
||||
libssl-dev \
|
||||
pandoc \
|
||||
bzip2
|
||||
user: "1000"
|
||||
description: Install system prerequisites (including pandoc for fetch_url_via_curl)
|
||||
- command: "curl -LsSf https://astral.sh/uv/install.sh | sh"
|
||||
user: "1000"
|
||||
description: Install uv (required for Python-based custom tools)
|
||||
- command: |
|
||||
set -euo pipefail
|
||||
USQL_VERSION="0.19.20"
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) USQL_ARCH=amd64 ;;
|
||||
aarch64) USQL_ARCH=arm64 ;;
|
||||
*) echo "Unsupported arch for usql install: $ARCH" >&2; exit 1 ;;
|
||||
esac
|
||||
curl -sSL "https://github.com/xo/usql/releases/download/v${USQL_VERSION}/usql_static-${USQL_VERSION}-linux-${USQL_ARCH}.tar.bz2" -o /tmp/usql.tar.bz2
|
||||
sudo tar -xjf /tmp/usql.tar.bz2 -C /usr/local/bin
|
||||
sudo chmod +x /usr/local/bin/usql
|
||||
rm -f /tmp/usql.tar.bz2
|
||||
user: "1000"
|
||||
description: Install the usql universal SQL CLI (used by the built-in sql agent and execute_sql_code tool)
|
||||
- command: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y \
|
||||
--default-toolchain stable \
|
||||
--profile minimal \
|
||||
--target x86_64-unknown-linux-musl
|
||||
. "$HOME/.cargo/env"
|
||||
cargo install --locked coyote-ai
|
||||
user: "1000"
|
||||
description: Install Coyote AI CLI via Rust's Cargo
|
||||
|
||||
startup:
|
||||
- command: ["sh", "-c", "test -f \"$HOME/.config/coyote/config.yaml\" || coyote --info >/dev/null 2>&1 || true"]
|
||||
user: "1000"
|
||||
background: false
|
||||
description: Bootstrap Coyote config directory on first sandbox start
|
||||
|
||||
memory: |
|
||||
## Sandbox environment
|
||||
|
||||
You are running inside a Docker sandbox launched via `sbx run coyote`. The
|
||||
user's project workspace is mounted at its absolute host path and is the
|
||||
current working directory. `sudo` is passwordless; use it for system
|
||||
package installs.
|
||||
|
||||
Coyote's configuration lives at `~/.config/coyote/` and logs at
|
||||
`~/.cache/coyote/coyote.log`. Persistence is enabled, so config, sessions,
|
||||
vault state, OAuth tokens, and installed tools survive sandbox restarts.
|
||||
|
||||
LLM provider credentials are forwarded by the sandbox HTTP proxy. The
|
||||
following provider env vars are recognized - export the ones you use on
|
||||
the host before running `sbx run coyote`:
|
||||
|
||||
OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY / GOOGLE_API_KEY,
|
||||
COHERE_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, AI21_API_KEY,
|
||||
CLOUDFLARE_API_KEY, DEEPINFRA_API_KEY, DEEPSEEK_API_KEY,
|
||||
MISTRAL_API_KEY, PERPLEXITY_API_KEY, VOYAGE_API_KEY, XAI_API_KEY,
|
||||
JINA_API_KEY, ERNIE_API_KEY, HUNYUAN_API_KEY, MINIMAX_API_KEY,
|
||||
MOONSHOT_API_KEY, DASHSCOPE_API_KEY (Qwen), ZHIPUAI_API_KEY
|
||||
|
||||
Inside the sandbox these appear as the placeholder string `proxy-managed`;
|
||||
the proxy substitutes the real value at request time. OAuth flows for
|
||||
Claude Pro/Max and Gemini are also allow-listed.
|
||||
|
||||
Bedrock (AWS) and VertexAI (Google Cloud) use signed/OAuth-token requests
|
||||
that the proxy cannot rewrite. Their domains are allow-listed but you must
|
||||
inject credentials yourself via `sbx run --env AWS_ACCESS_KEY_ID=...` or
|
||||
a mixin kit that mounts a service-account JSON.
|
||||
|
||||
Useful first-run commands:
|
||||
- `coyote --info` # show config paths and resolved settings
|
||||
- `coyote --list-secrets` # initialise the local vault
|
||||
- `coyote --authenticate <client>` # OAuth flow (Claude Pro/Max, Gemini)
|
||||
@@ -0,0 +1,33 @@
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
name: vault-aws-secrets-manager
|
||||
description: >
|
||||
Installs the AWS CLI v2 so the Coyote vault can read secrets from AWS
|
||||
Secrets Manager inside the sandbox. The AWS Rust SDK does not strictly
|
||||
require the CLI, but most users authenticate via `aws sso login` or
|
||||
`aws configure`, which need the CLI to be installed. After install, run
|
||||
the appropriate auth command in the sandbox; cached credentials persist
|
||||
for the lifetime of the sandbox.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
- "awscli.amazonaws.com:443"
|
||||
- "sts.amazonaws.com:443"
|
||||
- "*.sts.amazonaws.com:443"
|
||||
- "*.secretsmanager.amazonaws.com:443"
|
||||
- "*.amazonaws.com:443"
|
||||
- "*.awsapps.com:443"
|
||||
|
||||
commands:
|
||||
install:
|
||||
- command: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y unzip
|
||||
ARCH=$(uname -m)
|
||||
curl -sSL "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscliv2.zip
|
||||
unzip -q /tmp/awscliv2.zip -d /tmp
|
||||
sudo /tmp/aws/install
|
||||
rm -rf /tmp/awscliv2.zip /tmp/aws
|
||||
user: "1000"
|
||||
description: Install AWS CLI v2 from the official installer
|
||||
@@ -0,0 +1,24 @@
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
name: vault-azure-key-vault
|
||||
description: >
|
||||
Installs the Azure CLI (`az`) so the Coyote vault can read secrets from
|
||||
Azure Key Vault inside the sandbox. After install, run `az login` in the
|
||||
sandbox to authenticate; the session token persists for the lifetime of
|
||||
the sandbox.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
- "aka.ms:443"
|
||||
- "packages.microsoft.com:443"
|
||||
- "azurecliprod.blob.core.windows.net:443"
|
||||
- "login.microsoftonline.com:443"
|
||||
- "graph.microsoft.com:443"
|
||||
- "management.azure.com:443"
|
||||
- "*.vault.azure.net:443"
|
||||
|
||||
commands:
|
||||
install:
|
||||
- command: "curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash"
|
||||
user: "1000"
|
||||
description: Install Azure CLI via Microsoft's official install script
|
||||
@@ -0,0 +1,34 @@
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
name: vault-gcp-secret-manager
|
||||
description: >
|
||||
Installs the Google Cloud CLI (`gcloud`) so the Coyote vault can read
|
||||
secrets from GCP Secret Manager inside the sandbox. The GCP Rust SDK does
|
||||
not strictly require the CLI, but most users authenticate via
|
||||
`gcloud auth application-default login`, which needs the CLI to be
|
||||
installed. After install, run that command in the sandbox; the ADC file
|
||||
persists for the lifetime of the sandbox.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
- "packages.cloud.google.com:443"
|
||||
- "accounts.google.com:443"
|
||||
- "oauth2.googleapis.com:443"
|
||||
- "secretmanager.googleapis.com:443"
|
||||
- "cloudresourcemanager.googleapis.com:443"
|
||||
- "*.googleapis.com:443"
|
||||
|
||||
commands:
|
||||
install:
|
||||
- command: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y apt-transport-https ca-certificates gnupg
|
||||
echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" \
|
||||
| sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list >/dev/null
|
||||
curl -sSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \
|
||||
| sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y google-cloud-cli
|
||||
user: "1000"
|
||||
description: Install gcloud CLI from Google's official apt repository
|
||||
@@ -0,0 +1,30 @@
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
name: vault-gopass
|
||||
description: >
|
||||
Installs `gopass` and `gpg` so the Coyote vault can read secrets from a
|
||||
gopass store inside the sandbox. The store must be cloned manually
|
||||
(gopass walks a user-specific git remote, so v1 only allowlists github.com
|
||||
and gitlab.com; add other hosts via a user mixin if needed). After install,
|
||||
run `gopass setup` or `gopass clone <remote>` in the sandbox.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
- "github.com:443"
|
||||
- "api.github.com:443"
|
||||
- "objects.githubusercontent.com:443"
|
||||
- "gitlab.com:443"
|
||||
|
||||
commands:
|
||||
install:
|
||||
- command: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gnupg2 git
|
||||
GOPASS_VERSION="1.15.13"
|
||||
ARCH=$(dpkg --print-architecture)
|
||||
curl -sSL "https://github.com/gopasspw/gopass/releases/download/v${GOPASS_VERSION}/gopass_${GOPASS_VERSION}_linux_${ARCH}.deb" -o /tmp/gopass.deb
|
||||
sudo dpkg -i /tmp/gopass.deb
|
||||
rm -f /tmp/gopass.deb
|
||||
user: "1000"
|
||||
description: Install gnupg2, git, and gopass from the official .deb release
|
||||
@@ -0,0 +1,31 @@
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
name: vault-one-password
|
||||
description: >
|
||||
Installs the 1Password CLI (`op`) so the Coyote vault can decrypt secrets
|
||||
inside the sandbox. After install, run `op signin` in the sandbox to
|
||||
authenticate; credentials persist for the lifetime of the sandbox.
|
||||
|
||||
network:
|
||||
allowedDomains:
|
||||
- "downloads.1password.com:443"
|
||||
- "cache.agilebits.com:443"
|
||||
- "my.1password.com:443"
|
||||
- "my.1password.eu:443"
|
||||
- "my.1password.ca:443"
|
||||
- "events.1password.com:443"
|
||||
|
||||
commands:
|
||||
install:
|
||||
- command: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y unzip
|
||||
OP_VERSION="v2.30.3"
|
||||
ARCH=$(dpkg --print-architecture)
|
||||
curl -sSL "https://cache.agilebits.com/dist/1P/op2/pkg/${OP_VERSION}/op_linux_${ARCH}_${OP_VERSION}.zip" -o /tmp/op.zip
|
||||
sudo unzip -od /usr/local/bin /tmp/op.zip op
|
||||
sudo chmod +x /usr/local/bin/op
|
||||
rm -f /tmp/op.zip
|
||||
user: "1000"
|
||||
description: Install 1Password CLI from the official archive
|
||||
@@ -51,6 +51,8 @@ enabled_skills: # Optional list of skills available when this a
|
||||
inject_skill_instructions: true # Inject a short hint pointing the model at `skill__list` when skills are enabled
|
||||
# (default: true). Suppressed automatically when no skills are available.
|
||||
skill_instructions: null # Custom text for the skill hint (optional; uses built-in default if null)
|
||||
memory: null # Per-agent memory override (default: inherit). Set to `false` to disable memory
|
||||
# for this agent regardless of workspace/global presence. See the Memory wiki page.
|
||||
|
||||
dynamic_instructions: false # Whether to use dynamic instructions for the agent; if false, static instructions are used
|
||||
instructions: | # Static instructions for the agent; ignored if dynamic instructions are used
|
||||
|
||||
@@ -176,6 +176,19 @@ summarization_prompt: > # The text prompt used for creating a concise s
|
||||
summary_context_prompt: > # The text prompt used for including the summary of the entire session as context to the model
|
||||
'This is a summary of the chat history as a recap: '
|
||||
|
||||
# ---- Memory ----
|
||||
# See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information.
|
||||
# Memory is opt-in by workspace presence (a `COYOTE.md` or `.coyote/memory/MEMORY.md`)
|
||||
# and global presence (`<config_dir>/memory/MEMORY.md`). Set `memory: false` to disable
|
||||
# even when memory files exist. The cascade is: agent > session > role > app.
|
||||
# Bootstrap with `coyote --init-memory [global|workspace]` to create the marker file
|
||||
# the LLM needs before it will write any memory.
|
||||
memory: null # null = enabled when memory exists on disk; true = force on; false = force off
|
||||
memory_cap_with_tools: null # Char cap for injected memory when function calling is available (default: 6000).
|
||||
# Only MEMORY.md indexes are injected; the LLM uses memory__read to fetch drill files.
|
||||
memory_cap_without_tools: null # Char cap when function calling is unavailable (default: 12000).
|
||||
# Indexes plus drill file bodies are injected up to this cap.
|
||||
|
||||
# ---- RAG ----
|
||||
# See the [RAG Docs](https://github.com/Dark-Alex-17/coyote/wiki/RAG) for more details.
|
||||
rag_embedding_model: null # Specifies the embedding model used for context retrieval
|
||||
|
||||
@@ -22,6 +22,8 @@ enabled_skills: # Skills available when this role is activ
|
||||
inject_skill_instructions: true # Inject a short hint pointing the model at `skill__list` when skills are enabled
|
||||
# (default: true). Suppressed automatically when no skills are available.
|
||||
skill_instructions: null # Custom text for the skill hint (optional; uses built-in default if null)
|
||||
memory: null # Per-role memory override (default: inherit). Set to `false` to disable memory
|
||||
# when this role is active. See the Memory wiki page.
|
||||
|
||||
prompt: null # A custom prompt to use for this role that will immediately query
|
||||
# the model for output instead of using the instructions below
|
||||
|
||||
+32
@@ -329,6 +329,14 @@
|
||||
# - https://docs.anthropic.com/en/api/messages
|
||||
- provider: claude
|
||||
models:
|
||||
- name: claude-fable-5
|
||||
max_input_tokens: 1000000
|
||||
max_output_tokens: 128000
|
||||
require_max_tokens: true
|
||||
input_price: 10
|
||||
output_price: 50
|
||||
supports_function_calling: true
|
||||
supports_vision: true
|
||||
- name: claude-opus-4-8
|
||||
max_input_tokens: 1000000
|
||||
max_output_tokens: 128000
|
||||
@@ -867,6 +875,14 @@
|
||||
max_input_tokens: 1048576
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: claude-fable-5
|
||||
max_input_tokens: 1000000
|
||||
max_output_tokens: 128000
|
||||
require_max_tokens: true
|
||||
input_price: 10
|
||||
output_price: 50
|
||||
supports_function_calling: true
|
||||
supports_vision: true
|
||||
- name: claude-opus-4-8
|
||||
max_input_tokens: 1000000
|
||||
max_output_tokens: 128000
|
||||
@@ -1038,6 +1054,14 @@
|
||||
# - https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference-call.html
|
||||
- provider: bedrock
|
||||
models:
|
||||
- name: us.anthropic.claude-fable-5
|
||||
max_input_tokens: 1000000
|
||||
max_output_tokens: 128000
|
||||
require_max_tokens: true
|
||||
input_price: 10
|
||||
output_price: 50
|
||||
supports_function_calling: true
|
||||
supports_vision: true
|
||||
- name: us.anthropic.claude-opus-4-8
|
||||
max_input_tokens: 1000000
|
||||
max_output_tokens: 128000
|
||||
@@ -1729,6 +1753,14 @@
|
||||
max_input_tokens: 131072
|
||||
input_price: 0.1
|
||||
output_price: 0.2
|
||||
- name: anthropic/claude-fable-5
|
||||
max_input_tokens: 1000000
|
||||
max_output_tokens: 128000
|
||||
require_max_tokens: true
|
||||
input_price: 10
|
||||
output_price: 50
|
||||
supports_function_calling: true
|
||||
supports_vision: true
|
||||
- name: anthropic/claude-opus-4-8
|
||||
max_input_tokens: 1000000
|
||||
max_output_tokens: 128000
|
||||
|
||||
+86
-3
@@ -4,9 +4,9 @@ use crate::cli::completer::{
|
||||
ShellCompletion, agent_completer, macro_completer, model_completer, rag_completer,
|
||||
role_completer, secrets_completer, session_completer,
|
||||
};
|
||||
use crate::config::{AssetCategory, InstallFilter};
|
||||
use crate::config::{AssetCategory, InstallFilter, MemoryScope};
|
||||
use anyhow::{Context, Result};
|
||||
use clap::ValueHint;
|
||||
use clap::{ArgGroup, ValueHint};
|
||||
use clap::{Parser, crate_authors, crate_description, crate_version};
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use is_terminal::IsTerminal;
|
||||
@@ -27,7 +27,20 @@ use std::io::{Read, stdin};
|
||||
{usage-heading} {usage}
|
||||
|
||||
{all-args}{after-help}
|
||||
"
|
||||
",
|
||||
group(
|
||||
ArgGroup::new("sbx-mode")
|
||||
.args(["sandbox", "fresh", "no_mixins"])
|
||||
.multiple(true)
|
||||
.conflicts_with_all([
|
||||
"model", "prompt", "role", "session", "agent", "rag", "rebuild_rag",
|
||||
"macro_name", "execute", "code", "file", "no_stream", "no_memory",
|
||||
"init_memory", "dry_run", "info", "build_tools", "install",
|
||||
"install_from", "sync_models", "list_models", "list_roles",
|
||||
"list_sessions", "list_agents", "list_rags", "list_macros",
|
||||
"list_skills", "skill", "tail_logs", "completions", "update",
|
||||
])
|
||||
),
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// Select a LLM model
|
||||
@@ -75,6 +88,12 @@ pub struct Cli {
|
||||
/// Turn off stream mode
|
||||
#[arg(short = 'S', long)]
|
||||
pub no_stream: bool,
|
||||
/// Disable memory for this invocation
|
||||
#[arg(long)]
|
||||
pub no_memory: bool,
|
||||
/// Bootstrap a memory marker so coyote begins loading memory next run
|
||||
#[arg(long, value_name = "SCOPE", value_enum)]
|
||||
pub init_memory: Option<MemoryScope>,
|
||||
/// Display the message without sending it
|
||||
#[arg(long)]
|
||||
pub dry_run: bool,
|
||||
@@ -161,6 +180,15 @@ pub struct Cli {
|
||||
/// With --update, update even if Coyote was installed via a package manager
|
||||
#[arg(long, requires = "update")]
|
||||
pub force: bool,
|
||||
/// Launch Coyote inside a Docker sandbox (via `sbx`); name defaults to current directory basename
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub sandbox: Option<Option<String>>,
|
||||
/// Create the sandbox without bootstrapping the host config or vault password file
|
||||
#[arg(long, requires = "sandbox")]
|
||||
pub fresh: bool,
|
||||
/// Skip discovery and application of all sbx mixins (user and built-in)
|
||||
#[arg(long, requires = "sandbox")]
|
||||
pub no_mixins: bool,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
@@ -489,4 +517,59 @@ mod tests {
|
||||
fn parse_force_without_update_fails() {
|
||||
assert!(Cli::try_parse_from(["coyote", "--force"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sandbox_flag_no_value() {
|
||||
let cli = parse(&["--sandbox"]);
|
||||
assert_eq!(cli.sandbox, Some(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sandbox_flag_with_name() {
|
||||
let cli = parse(&["--sandbox", "my-box"]);
|
||||
assert_eq!(cli.sandbox, Some(Some("my-box".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sandbox_is_exclusive() {
|
||||
assert!(Cli::try_parse_from(["coyote", "--sandbox", "--agent", "foo"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fresh_flag_requires_sandbox() {
|
||||
assert!(Cli::try_parse_from(["coyote", "--fresh"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fresh_flag_with_sandbox() {
|
||||
let cli = parse(&["--sandbox", "--fresh"]);
|
||||
assert_eq!(cli.sandbox, Some(None));
|
||||
assert!(cli.fresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fresh_flag_with_named_sandbox() {
|
||||
let cli = parse(&["--sandbox", "foo", "--fresh"]);
|
||||
assert_eq!(cli.sandbox, Some(Some("foo".to_string())));
|
||||
assert!(cli.fresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_no_mixins_requires_sandbox() {
|
||||
assert!(Cli::try_parse_from(["coyote", "--no-mixins"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_no_mixins_with_sandbox() {
|
||||
let cli = parse(&["--sandbox", "--no-mixins"]);
|
||||
assert!(cli.no_mixins);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sandbox_with_fresh_and_no_mixins() {
|
||||
let cli = parse(&["--sandbox", "foo", "--fresh", "--no-mixins"]);
|
||||
assert_eq!(cli.sandbox, Some(Some("foo".to_string())));
|
||||
assert!(cli.fresh);
|
||||
assert!(cli.no_mixins);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub struct AzureOpenAIConfig {
|
||||
impl AzureOpenAIClient {
|
||||
config_get_fn!(api_base, get_api_base);
|
||||
config_get_fn!(api_key, get_api_key);
|
||||
|
||||
|
||||
create_client_config!([
|
||||
(
|
||||
"api_base",
|
||||
|
||||
@@ -119,7 +119,11 @@ fn prepare_chat_completions(
|
||||
format!("{base_url}/google/models/{model_name}:{func}")
|
||||
}
|
||||
ModelCategory::Claude => {
|
||||
format!("{base_url}/anthropic/models/{model_name}:streamRawPredict")
|
||||
let func = match data.stream {
|
||||
true => "streamRawPredict",
|
||||
false => "rawPredict",
|
||||
};
|
||||
format!("{base_url}/anthropic/models/{model_name}:{func}")
|
||||
}
|
||||
ModelCategory::Mistral => {
|
||||
let func = match data.stream {
|
||||
|
||||
+22
-1
@@ -2,6 +2,7 @@ use super::*;
|
||||
|
||||
use crate::{
|
||||
client::Model,
|
||||
config::memory,
|
||||
function::{Functions, run_llm_function},
|
||||
};
|
||||
|
||||
@@ -19,7 +20,7 @@ use fancy_regex::Captures;
|
||||
use inquire::{Text, validator::Validation};
|
||||
use rust_embed::Embed;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{ffi::OsStr, path::Path};
|
||||
use std::{env, ffi::OsStr, path::Path};
|
||||
|
||||
const DEFAULT_AGENT_NAME: &str = "rag";
|
||||
|
||||
@@ -214,6 +215,20 @@ impl Agent {
|
||||
functions.append_skill_functions();
|
||||
}
|
||||
|
||||
if app.function_calling_support
|
||||
&& !matches!(agent_config.memory, Some(false))
|
||||
&& !matches!(app.memory, Some(false))
|
||||
{
|
||||
let memory_exists = paths::global_memory_index_path().exists()
|
||||
|| env::current_dir()
|
||||
.ok()
|
||||
.and_then(|cwd| memory::discover_workspace_memory(&cwd))
|
||||
.is_some();
|
||||
if memory_exists {
|
||||
functions.append_memory_functions();
|
||||
}
|
||||
}
|
||||
|
||||
agent_config.replace_tools_placeholder(&functions);
|
||||
|
||||
Ok(Self {
|
||||
@@ -352,6 +367,10 @@ impl Agent {
|
||||
self.config.enabled_skills.as_deref()
|
||||
}
|
||||
|
||||
pub fn memory(&self) -> Option<bool> {
|
||||
self.config.memory
|
||||
}
|
||||
|
||||
pub fn set_skills_enabled(&mut self, value: Option<bool>) {
|
||||
self.config.skills_enabled = value;
|
||||
}
|
||||
@@ -638,6 +657,8 @@ pub struct AgentConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub skill_instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub memory: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub compression_threshold: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
|
||||
@@ -64,6 +64,10 @@ pub struct AppConfig {
|
||||
pub summarization_prompt: Option<String>,
|
||||
pub summary_context_prompt: Option<String>,
|
||||
|
||||
pub memory: Option<bool>,
|
||||
pub memory_cap_with_tools: Option<usize>,
|
||||
pub memory_cap_without_tools: Option<usize>,
|
||||
|
||||
pub rag_embedding_model: Option<String>,
|
||||
pub rag_reranker_model: Option<String>,
|
||||
pub rag_top_k: usize,
|
||||
@@ -132,6 +136,10 @@ impl Default for AppConfig {
|
||||
summarization_prompt: None,
|
||||
summary_context_prompt: None,
|
||||
|
||||
memory: None,
|
||||
memory_cap_with_tools: None,
|
||||
memory_cap_without_tools: None,
|
||||
|
||||
rag_embedding_model: None,
|
||||
rag_reranker_model: None,
|
||||
rag_top_k: 5,
|
||||
@@ -201,6 +209,10 @@ impl AppConfig {
|
||||
summarization_prompt: config.summarization_prompt,
|
||||
summary_context_prompt: config.summary_context_prompt,
|
||||
|
||||
memory: config.memory,
|
||||
memory_cap_with_tools: config.memory_cap_with_tools,
|
||||
memory_cap_without_tools: config.memory_cap_without_tools,
|
||||
|
||||
rag_embedding_model: config.rag_embedding_model,
|
||||
rag_reranker_model: config.rag_reranker_model,
|
||||
rag_top_k: config.rag_top_k,
|
||||
@@ -262,10 +274,25 @@ impl AppConfig {
|
||||
|
||||
pub fn vault_password_file(&self) -> PathBuf {
|
||||
match &self.vault_password_file {
|
||||
Some(path) => match path.exists() {
|
||||
true => path.clone(),
|
||||
false => gman::config::Config::local_provider_password_file(),
|
||||
},
|
||||
Some(path) => {
|
||||
if path.exists() {
|
||||
return path.clone();
|
||||
}
|
||||
|
||||
if let Some(translated) = paths::translate_sandboxed_home_path(path)
|
||||
&& translated.exists()
|
||||
{
|
||||
info!(
|
||||
"vault_password_file '{}' not found; resolved to sandboxed path '{}'",
|
||||
path.display(),
|
||||
translated.display()
|
||||
);
|
||||
|
||||
return translated;
|
||||
}
|
||||
|
||||
gman::config::Config::local_provider_password_file()
|
||||
}
|
||||
None => gman::config::Config::local_provider_password_file(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,733 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::{
|
||||
GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME,
|
||||
WORKSPACE_MEMORY_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME, paths,
|
||||
};
|
||||
|
||||
pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000;
|
||||
pub const DEFAULT_MEMORY_CAP_WITHOUT_TOOLS: usize = 12_000;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WorkspaceMemory {
|
||||
Structured {
|
||||
workspace_root: PathBuf,
|
||||
dir: PathBuf,
|
||||
},
|
||||
Lite {
|
||||
workspace_root: PathBuf,
|
||||
file: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn discover_workspace_memory(start: &Path) -> Option<WorkspaceMemory> {
|
||||
for dir in start.ancestors() {
|
||||
let structured = dir.join(WORKSPACE_MEMORY_DIR_NAME).join(MEMORY_DIR_NAME);
|
||||
if structured.join(MEMORY_INDEX_FILE_NAME).exists() {
|
||||
return Some(WorkspaceMemory::Structured {
|
||||
workspace_root: dir.to_path_buf(),
|
||||
dir: structured,
|
||||
});
|
||||
}
|
||||
|
||||
let lite = dir.join(WORKSPACE_MEMORY_FILE_NAME);
|
||||
if lite.exists() {
|
||||
return Some(WorkspaceMemory::Lite {
|
||||
workspace_root: dir.to_path_buf(),
|
||||
file: lite,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn find_git_root(start: &Path) -> Option<PathBuf> {
|
||||
for dir in start.ancestors() {
|
||||
if dir.join(GIT_DIR_NAME).exists() {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn bootstrap_workspace_memory(git_root: &Path) -> Result<PathBuf> {
|
||||
let mem_dir = paths::workspace_memory_dir_for(git_root);
|
||||
fs::create_dir_all(&mem_dir)
|
||||
.with_context(|| format!("create memory dir {}", mem_dir.display()))?;
|
||||
|
||||
let index_path = mem_dir.join(MEMORY_INDEX_FILE_NAME);
|
||||
if !index_path.exists() {
|
||||
fs::write(&index_path, "# Workspace Memory Index\n\n")
|
||||
.with_context(|| format!("write {}", index_path.display()))?;
|
||||
}
|
||||
|
||||
let gitignore_appended = append_gitignore_entry(git_root)?;
|
||||
let suffix = if gitignore_appended {
|
||||
" (appended .coyote/memory/ to .gitignore)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
warn!(
|
||||
"auto-bootstrapped workspace memory at {}{}",
|
||||
mem_dir.display(),
|
||||
suffix
|
||||
);
|
||||
|
||||
Ok(mem_dir)
|
||||
}
|
||||
|
||||
fn append_gitignore_entry(git_root: &Path) -> Result<bool> {
|
||||
let gitignore = git_root.join(GITIGNORE_FILE_NAME);
|
||||
let entry = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}/");
|
||||
let entry_no_slash = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}");
|
||||
|
||||
let existing = fs::read_to_string(&gitignore).unwrap_or_default();
|
||||
let already_present = existing.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed == entry || trimmed == entry_no_slash
|
||||
});
|
||||
|
||||
if already_present {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let new_content = if existing.is_empty() {
|
||||
format!("{entry}\n")
|
||||
} else if existing.ends_with('\n') {
|
||||
format!("{existing}{entry}\n")
|
||||
} else {
|
||||
format!("{existing}\n{entry}\n")
|
||||
};
|
||||
|
||||
fs::write(&gitignore, new_content).with_context(|| format!("write {}", gitignore.display()))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
pub struct MemoryFrontmatter {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MemoryFile {
|
||||
pub path: PathBuf,
|
||||
pub frontmatter: MemoryFrontmatter,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl MemoryFile {
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let raw = fs::read_to_string(path)
|
||||
.with_context(|| format!("read memory file {}", path.display()))?;
|
||||
let (frontmatter, body) = parse_frontmatter(&raw)
|
||||
.with_context(|| format!("parse frontmatter in {}", path.display()))?;
|
||||
|
||||
Ok(Self {
|
||||
path: path.to_path_buf(),
|
||||
frontmatter,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let frontmatter_yaml = serde_yaml::to_string(&self.frontmatter)?;
|
||||
let content = format!("---\n{}---\n\n{}", frontmatter_yaml, self.body);
|
||||
|
||||
fs::write(&self.path, content)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn char_len(&self) -> usize {
|
||||
self.body.chars().count()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_frontmatter(raw: &str) -> Result<(MemoryFrontmatter, String)> {
|
||||
let trimmed = raw.trim_start();
|
||||
if !trimmed.starts_with("---") {
|
||||
return Ok((MemoryFrontmatter::default(), raw.to_string()));
|
||||
}
|
||||
|
||||
let after = &trimmed[3..];
|
||||
let Some(end) = after.find("\n---") else {
|
||||
return Ok((MemoryFrontmatter::default(), raw.to_string()));
|
||||
};
|
||||
let yaml = &after[..end];
|
||||
let body = after[end + 4..].trim_start_matches('\n').to_string();
|
||||
let frontmatter: MemoryFrontmatter =
|
||||
serde_yaml::from_str(yaml.trim()).context("parse YAML frontmatter")?;
|
||||
|
||||
Ok((frontmatter, body))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MemoryStore {
|
||||
pub global_dir: PathBuf,
|
||||
pub workspace: Option<WorkspaceMemory>,
|
||||
}
|
||||
|
||||
impl MemoryStore {
|
||||
pub fn new(cwd: &Path) -> Self {
|
||||
Self {
|
||||
global_dir: paths::global_memory_dir(),
|
||||
workspace: discover_workspace_memory(cwd),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_global_index(&self) -> Result<Option<String>> {
|
||||
let path = self.global_dir.join(MEMORY_INDEX_FILE_NAME);
|
||||
|
||||
if path.exists() {
|
||||
Ok(Some(fs::read_to_string(path)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_workspace_index(&self) -> Result<Option<String>> {
|
||||
match &self.workspace {
|
||||
None => Ok(None),
|
||||
Some(WorkspaceMemory::Lite { file, .. }) => Ok(Some(fs::read_to_string(file)?)),
|
||||
Some(WorkspaceMemory::Structured { dir, .. }) => {
|
||||
let index = dir.join(MEMORY_INDEX_FILE_NAME);
|
||||
if index.exists() {
|
||||
Ok(Some(fs::read_to_string(index)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_files(&self) -> Result<Vec<MemoryFile>> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
if self.global_dir.exists() {
|
||||
collect_md_files(&self.global_dir, &mut out)?;
|
||||
}
|
||||
|
||||
if let Some(WorkspaceMemory::Structured { dir, .. }) = &self.workspace {
|
||||
collect_md_files(dir, &mut out)?;
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_memory_section(
|
||||
store: &MemoryStore,
|
||||
with_tools: bool,
|
||||
cap: usize,
|
||||
) -> Result<Option<String>> {
|
||||
let global_index = store.load_global_index()?;
|
||||
let workspace_index = store.load_workspace_index()?;
|
||||
|
||||
if global_index.is_none() && workspace_index.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut buf = String::from("<memory>\n");
|
||||
let mut consumed = 0usize;
|
||||
|
||||
if let Some(s) = &global_index {
|
||||
buf.push_str("<global_index>\n");
|
||||
buf.push_str(s);
|
||||
buf.push_str("\n</global_index>\n");
|
||||
consumed += s.chars().count();
|
||||
}
|
||||
|
||||
if let Some(s) = &workspace_index {
|
||||
buf.push_str("<workspace_index>\n");
|
||||
buf.push_str(s);
|
||||
buf.push_str("\n</workspace_index>\n");
|
||||
consumed += s.chars().count();
|
||||
}
|
||||
|
||||
if consumed > cap {
|
||||
warn!(
|
||||
"memory indexes ({} chars) exceed cap ({} chars); injecting fully - \
|
||||
consider raising memory_cap_* in config or shrinking MEMORY.md",
|
||||
consumed, cap
|
||||
);
|
||||
}
|
||||
|
||||
if !with_tools {
|
||||
let mut budget = cap.saturating_sub(consumed);
|
||||
let mut files = store.list_files()?;
|
||||
files.sort_by(|a, b| a.frontmatter.name.cmp(&b.frontmatter.name));
|
||||
let mut omitted = 0usize;
|
||||
for f in files {
|
||||
let needed = f.body.chars().count() + 50;
|
||||
if needed > budget {
|
||||
omitted += 1;
|
||||
continue;
|
||||
}
|
||||
buf.push_str(&format!("<file name=\"{}\">\n", f.frontmatter.name));
|
||||
buf.push_str(&f.body);
|
||||
buf.push_str("\n</file>\n");
|
||||
budget = budget.saturating_sub(needed);
|
||||
}
|
||||
|
||||
if omitted > 0 {
|
||||
buf.push_str(&format!(
|
||||
"<!-- {} memory file(s) omitted; enable function calling for full access -->\n",
|
||||
omitted
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
buf.push_str("</memory>");
|
||||
Ok(Some(buf))
|
||||
}
|
||||
|
||||
fn collect_md_files(dir: &Path, out: &mut Vec<MemoryFile>) -> Result<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some(MEMORY_INDEX_FILE_NAME) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match MemoryFile::load(&path) {
|
||||
Ok(f) => out.push(f),
|
||||
Err(e) => warn!("skip malformed memory file {}: {}", path.display(), e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{env, time};
|
||||
use time::SystemTime;
|
||||
|
||||
fn temp_root(label: &str) -> PathBuf {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let root = env::temp_dir().join(format!("coyote-memory-{label}-{unique}"));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_global_and_workspace_indexes_from_test_dirs() {
|
||||
let root = temp_root("phase1");
|
||||
let workspace = root.join("workspace");
|
||||
let workspace_memory_dir = workspace
|
||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
||||
.join(MEMORY_DIR_NAME);
|
||||
fs::create_dir_all(&workspace_memory_dir).unwrap();
|
||||
fs::write(
|
||||
workspace_memory_dir.join(MEMORY_INDEX_FILE_NAME),
|
||||
"workspace-content",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let global = root.join("global");
|
||||
fs::create_dir_all(&global).unwrap();
|
||||
fs::write(global.join(MEMORY_INDEX_FILE_NAME), "global-content").unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: global,
|
||||
workspace: discover_workspace_memory(&workspace),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
store.load_global_index().unwrap().as_deref(),
|
||||
Some("global-content")
|
||||
);
|
||||
assert_eq!(
|
||||
store.load_workspace_index().unwrap().as_deref(),
|
||||
Some("workspace-content")
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_discovery_prefers_structured_over_lite() {
|
||||
let root = temp_root("prefer");
|
||||
let workspace = root.join("ws");
|
||||
let structured = workspace
|
||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
||||
.join(MEMORY_DIR_NAME);
|
||||
fs::create_dir_all(&structured).unwrap();
|
||||
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "s").unwrap();
|
||||
fs::write(workspace.join(WORKSPACE_MEMORY_FILE_NAME), "l").unwrap();
|
||||
|
||||
let found = discover_workspace_memory(&workspace);
|
||||
assert!(matches!(found, Some(WorkspaceMemory::Structured { .. })));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_memory_section_returns_none_when_no_memory_exists() {
|
||||
let root = temp_root("none");
|
||||
let workspace = root.join("ws");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("global"),
|
||||
workspace: discover_workspace_memory(&workspace),
|
||||
};
|
||||
|
||||
assert!(build_memory_section(&store, true, 6_000).unwrap().is_none());
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_memory_section_injects_only_indexes_with_tools_on() {
|
||||
let root = temp_root("indexes_only");
|
||||
let workspace = root.join("ws");
|
||||
let structured = workspace
|
||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
||||
.join(MEMORY_DIR_NAME);
|
||||
fs::create_dir_all(&structured).unwrap();
|
||||
fs::write(
|
||||
structured.join(MEMORY_INDEX_FILE_NAME),
|
||||
"workspace-index-content",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
structured.join("foo.md"),
|
||||
"---\nname: foo\n---\nfoo body that should not appear\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("global"),
|
||||
workspace: discover_workspace_memory(&workspace),
|
||||
};
|
||||
|
||||
let section = build_memory_section(&store, true, 6_000)
|
||||
.unwrap()
|
||||
.expect("memory section should exist");
|
||||
assert!(section.contains("workspace-index-content"));
|
||||
assert!(!section.contains("foo body that should not appear"));
|
||||
assert!(section.starts_with("<memory>"));
|
||||
assert!(section.ends_with("</memory>"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_memory_section_injects_drill_bodies_alphabetically_without_tools() {
|
||||
let root = temp_root("drill_bodies");
|
||||
let workspace = root.join("ws");
|
||||
let structured = workspace
|
||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
||||
.join(MEMORY_DIR_NAME);
|
||||
fs::create_dir_all(&structured).unwrap();
|
||||
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
|
||||
fs::write(
|
||||
structured.join("zebra.md"),
|
||||
"---\nname: zebra\n---\nzebra body\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
structured.join("alpha.md"),
|
||||
"---\nname: alpha\n---\nalpha body\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("global"),
|
||||
workspace: discover_workspace_memory(&workspace),
|
||||
};
|
||||
|
||||
let section = build_memory_section(&store, false, 6_000)
|
||||
.unwrap()
|
||||
.expect("memory section should exist");
|
||||
let alpha_pos = section.find("alpha body").expect("alpha body missing");
|
||||
let zebra_pos = section.find("zebra body").expect("zebra body missing");
|
||||
assert!(alpha_pos < zebra_pos, "drill bodies must be alphabetical");
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_memory_section_omits_drill_bodies_when_cap_exceeded() {
|
||||
let root = temp_root("cap");
|
||||
let workspace = root.join("ws");
|
||||
let structured = workspace
|
||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
||||
.join(MEMORY_DIR_NAME);
|
||||
fs::create_dir_all(&structured).unwrap();
|
||||
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
|
||||
let big_body = "x".repeat(200);
|
||||
fs::write(
|
||||
structured.join("big.md"),
|
||||
format!("---\nname: big\n---\n{}\n", big_body),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("global"),
|
||||
workspace: discover_workspace_memory(&workspace),
|
||||
};
|
||||
|
||||
let section = build_memory_section(&store, false, 100)
|
||||
.unwrap()
|
||||
.expect("memory section should exist");
|
||||
assert!(!section.contains(&big_body));
|
||||
assert!(section.contains("memory file(s) omitted"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_frontmatter_extracts_yaml() {
|
||||
let raw = "---\nname: foo\ndescription: a thing\ntype: user\n---\nBody text\n";
|
||||
|
||||
let (fm, body) = parse_frontmatter(raw).unwrap();
|
||||
|
||||
assert_eq!(fm.name, "foo");
|
||||
assert_eq!(fm.description.as_deref(), Some("a thing"));
|
||||
assert_eq!(fm.kind.as_deref(), Some("user"));
|
||||
assert_eq!(body, "Body text\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_frontmatter_handles_missing_block() {
|
||||
let raw = "# Just markdown, no frontmatter\nbody";
|
||||
|
||||
let (fm, body) = parse_frontmatter(raw).unwrap();
|
||||
|
||||
assert_eq!(fm.name, "");
|
||||
assert!(fm.kind.is_none());
|
||||
assert_eq!(body, raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_frontmatter_handles_unterminated_block() {
|
||||
let raw = "---\nname: oops\nno closing delimiter\n# rest of doc";
|
||||
|
||||
let (fm, body) = parse_frontmatter(raw).unwrap();
|
||||
|
||||
assert_eq!(fm.name, "");
|
||||
assert_eq!(body, raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_file_save_and_load_roundtrip() {
|
||||
let root = temp_root("roundtrip");
|
||||
let path = root.join("test.md");
|
||||
let file = MemoryFile {
|
||||
path: path.clone(),
|
||||
frontmatter: MemoryFrontmatter {
|
||||
name: "test".into(),
|
||||
description: Some("a test".into()),
|
||||
kind: Some("user".into()),
|
||||
},
|
||||
body: "Hello world\nmore text".into(),
|
||||
};
|
||||
file.save().unwrap();
|
||||
let loaded = MemoryFile::load(&path).unwrap();
|
||||
assert_eq!(loaded.frontmatter.name, "test");
|
||||
assert_eq!(loaded.frontmatter.description.as_deref(), Some("a test"));
|
||||
assert_eq!(loaded.frontmatter.kind.as_deref(), Some("user"));
|
||||
assert_eq!(loaded.body, "Hello world\nmore text");
|
||||
|
||||
let raw = fs::read_to_string(&path).unwrap();
|
||||
assert!(raw.contains("type: user"), "kind must serialize as 'type:'");
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_walks_up_from_nested_dir() {
|
||||
let root = temp_root("walk_up");
|
||||
let workspace = root.join("ws");
|
||||
let mem_dir = workspace
|
||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
||||
.join(MEMORY_DIR_NAME);
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
|
||||
let nested = workspace.join("src").join("deep").join("path");
|
||||
fs::create_dir_all(&nested).unwrap();
|
||||
|
||||
let found = discover_workspace_memory(&nested);
|
||||
assert!(matches!(found, Some(WorkspaceMemory::Structured { .. })));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_root_returns_dir_containing_git_dir() {
|
||||
let root = temp_root("git_root");
|
||||
let repo = root.join("repo");
|
||||
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||
|
||||
assert_eq!(find_git_root(&repo), Some(repo.clone()));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_root_walks_up_from_nested_dir() {
|
||||
let root = temp_root("git_root_walk");
|
||||
let repo = root.join("repo");
|
||||
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||
let nested = repo.join("a").join("b").join("c");
|
||||
fs::create_dir_all(&nested).unwrap();
|
||||
|
||||
assert_eq!(find_git_root(&nested), Some(repo));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_root_treats_git_file_as_repo_marker() {
|
||||
let root = temp_root("git_root_worktree");
|
||||
let worktree = root.join("worktree");
|
||||
fs::create_dir_all(&worktree).unwrap();
|
||||
fs::write(
|
||||
worktree.join(GIT_DIR_NAME),
|
||||
"gitdir: /elsewhere/.git/worktrees/wt\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(find_git_root(&worktree), Some(worktree));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_root_returns_none_when_no_git() {
|
||||
let root = temp_root("git_root_missing");
|
||||
let bare = root.join("bare");
|
||||
fs::create_dir_all(&bare).unwrap();
|
||||
|
||||
assert_eq!(find_git_root(&bare), None);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_creates_structured_layout_and_index() {
|
||||
let root = temp_root("bootstrap_layout");
|
||||
let repo = root.join("repo");
|
||||
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||
|
||||
let mem_dir = bootstrap_workspace_memory(&repo).unwrap();
|
||||
|
||||
assert_eq!(mem_dir, paths::workspace_memory_dir_for(&repo));
|
||||
assert!(mem_dir.is_dir());
|
||||
let index = mem_dir.join(MEMORY_INDEX_FILE_NAME);
|
||||
assert!(index.exists());
|
||||
let body = fs::read_to_string(&index).unwrap();
|
||||
assert!(body.starts_with("# Workspace Memory Index"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_creates_gitignore_when_absent() {
|
||||
let root = temp_root("bootstrap_gi_new");
|
||||
let repo = root.join("repo");
|
||||
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||
|
||||
bootstrap_workspace_memory(&repo).unwrap();
|
||||
|
||||
let gi = repo.join(GITIGNORE_FILE_NAME);
|
||||
assert!(gi.exists());
|
||||
let body = fs::read_to_string(&gi).unwrap();
|
||||
assert!(body.contains(".coyote/memory/"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_appends_to_existing_gitignore_without_trailing_newline() {
|
||||
let root = temp_root("bootstrap_gi_append");
|
||||
let repo = root.join("repo");
|
||||
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||
fs::write(repo.join(GITIGNORE_FILE_NAME), "target/").unwrap();
|
||||
|
||||
bootstrap_workspace_memory(&repo).unwrap();
|
||||
|
||||
let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap();
|
||||
assert!(body.contains("target/"));
|
||||
assert!(body.contains(".coyote/memory/"));
|
||||
assert!(body.ends_with('\n'));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_is_idempotent_on_gitignore_entry() {
|
||||
let root = temp_root("bootstrap_gi_idempotent");
|
||||
let repo = root.join("repo");
|
||||
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||
let original = "target/\n.coyote/memory/\n";
|
||||
fs::write(repo.join(GITIGNORE_FILE_NAME), original).unwrap();
|
||||
|
||||
bootstrap_workspace_memory(&repo).unwrap();
|
||||
|
||||
let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap();
|
||||
assert_eq!(body, original, "gitignore must be untouched");
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_treats_entry_without_trailing_slash_as_present() {
|
||||
let root = temp_root("bootstrap_gi_no_slash");
|
||||
let repo = root.join("repo");
|
||||
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||
let original = ".coyote/memory\n";
|
||||
fs::write(repo.join(GITIGNORE_FILE_NAME), original).unwrap();
|
||||
|
||||
bootstrap_workspace_memory(&repo).unwrap();
|
||||
|
||||
let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap();
|
||||
assert_eq!(body, original);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_does_not_clobber_existing_index() {
|
||||
let root = temp_root("bootstrap_existing_index");
|
||||
let repo = root.join("repo");
|
||||
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||
let mem_dir = paths::workspace_memory_dir_for(&repo);
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
let preserved = "# Custom Index\n\n- [[foo]]: keep me\n";
|
||||
fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), preserved).unwrap();
|
||||
|
||||
bootstrap_workspace_memory(&repo).unwrap();
|
||||
|
||||
let body = fs::read_to_string(mem_dir.join(MEMORY_INDEX_FILE_NAME)).unwrap();
|
||||
assert_eq!(body, preserved);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod input;
|
||||
mod install_remote;
|
||||
mod macros;
|
||||
mod mcp_factory;
|
||||
pub(crate) mod memory;
|
||||
pub(crate) mod paths;
|
||||
pub(crate) mod prompts;
|
||||
mod rag_cache;
|
||||
@@ -138,6 +139,17 @@ const GLOBAL_TOOLS_DIR_NAME: &str = "tools";
|
||||
const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils";
|
||||
const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh";
|
||||
const MCP_FILE_NAME: &str = "mcp.json";
|
||||
const MEMORY_DIR_NAME: &str = "memory";
|
||||
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
|
||||
const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md";
|
||||
const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote";
|
||||
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
|
||||
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
|
||||
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
||||
const SBX_VAULT_MIXINS_DIR_NAME: &str = "sbx-vault-mixins";
|
||||
const SBX_MIXIN_KITS_DIR_NAME: &str = "sbx-mixin-kits";
|
||||
const GIT_DIR_NAME: &str = ".git";
|
||||
const GITIGNORE_FILE_NAME: &str = ".gitignore";
|
||||
const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [
|
||||
"execute_command.sh",
|
||||
"execute_py_code.py",
|
||||
@@ -226,6 +238,10 @@ pub struct Config {
|
||||
pub summarization_prompt: Option<String>,
|
||||
pub summary_context_prompt: Option<String>,
|
||||
|
||||
pub memory: Option<bool>,
|
||||
pub memory_cap_with_tools: Option<usize>,
|
||||
pub memory_cap_without_tools: Option<usize>,
|
||||
|
||||
pub rag_embedding_model: Option<String>,
|
||||
pub rag_reranker_model: Option<String>,
|
||||
pub rag_top_k: usize,
|
||||
@@ -294,6 +310,10 @@ impl Default for Config {
|
||||
summarization_prompt: None,
|
||||
summary_context_prompt: None,
|
||||
|
||||
memory: None,
|
||||
memory_cap_with_tools: None,
|
||||
memory_cap_without_tools: None,
|
||||
|
||||
rag_embedding_model: None,
|
||||
rag_reranker_model: None,
|
||||
rag_top_k: 5,
|
||||
@@ -350,6 +370,12 @@ impl AssetCategory {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
|
||||
pub enum MemoryScope {
|
||||
Global,
|
||||
Workspace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
|
||||
pub enum InstallFilter {
|
||||
Agents,
|
||||
@@ -646,6 +672,9 @@ bitflags::bitflags! {
|
||||
const SESSION = 1 << 2;
|
||||
const RAG = 1 << 3;
|
||||
const AGENT = 1 << 4;
|
||||
const FUNCTION_CALLING = 1 << 5;
|
||||
const AUTO_CONTINUE = 1 << 6;
|
||||
const SKILLS_ENABLED = 1 << 7;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+321
-5
@@ -2,8 +2,10 @@ use super::role::Role;
|
||||
use super::{
|
||||
AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME,
|
||||
ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME,
|
||||
GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, ModelsOverride, RAGS_DIR_NAME,
|
||||
ROLES_DIR_NAME, SKILLS_DIR_NAME,
|
||||
GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME,
|
||||
MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME,
|
||||
SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, SBX_VAULT_MIXINS_DIR_NAME,
|
||||
SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME,
|
||||
};
|
||||
use crate::client::ProviderModels;
|
||||
use crate::utils::{get_env_name, list_file_names, normalize_env_name};
|
||||
@@ -13,7 +15,7 @@ use log::LevelFilter;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs::{read_dir, read_to_string};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
if let Ok(v) = env::var(get_env_name("config_dir")) {
|
||||
@@ -31,8 +33,97 @@ pub fn local_path(name: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
pub fn cache_path() -> PathBuf {
|
||||
let base_dir = dirs::cache_dir().unwrap_or_else(env::temp_dir);
|
||||
base_dir.join(env!("CARGO_CRATE_NAME"))
|
||||
if let Ok(v) = env::var(get_env_name("cache_dir")) {
|
||||
PathBuf::from(v)
|
||||
} else if let Ok(v) = env::var("XDG_CACHE_HOME") {
|
||||
PathBuf::from(v).join(env!("CARGO_CRATE_NAME"))
|
||||
} else {
|
||||
let base_dir = dirs::cache_dir().unwrap_or_else(env::temp_dir);
|
||||
base_dir.join(env!("CARGO_CRATE_NAME"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sandbox_kit_override() -> Option<PathBuf> {
|
||||
env::var_os(get_env_name("sandbox_kit")).map(PathBuf::from)
|
||||
}
|
||||
|
||||
pub fn translate_sandboxed_home_path(path: &Path) -> Option<PathBuf> {
|
||||
env::var_os("IS_SANDBOX")?;
|
||||
|
||||
let s = path.to_str()?;
|
||||
|
||||
if let Some(translated) = translate_unix_home_style(s, "/home/") {
|
||||
return Some(translated);
|
||||
}
|
||||
|
||||
if let Some(translated) = translate_unix_home_style(s, "/Users/") {
|
||||
return Some(translated);
|
||||
}
|
||||
|
||||
translate_windows_users_path(s)
|
||||
}
|
||||
|
||||
fn translate_unix_home_style(s: &str, prefix: &str) -> Option<PathBuf> {
|
||||
let rest = s.strip_prefix(prefix)?;
|
||||
let (user, tail) = match rest.split_once('/') {
|
||||
Some((u, t)) => (u, t),
|
||||
None => (rest, ""),
|
||||
};
|
||||
|
||||
if user.is_empty() || user == "agent" {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(if tail.is_empty() {
|
||||
PathBuf::from("/home/agent")
|
||||
} else {
|
||||
PathBuf::from(format!("/home/agent/{tail}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn translate_windows_users_path(s: &str) -> Option<PathBuf> {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() < 4 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' || bytes[2] != b'\\' {
|
||||
return None;
|
||||
}
|
||||
|
||||
let after_drive = &s[3..];
|
||||
let rest = after_drive.strip_prefix("Users\\")?;
|
||||
let (user, tail) = match rest.split_once('\\') {
|
||||
Some((u, t)) => (u, t.replace('\\', "/")),
|
||||
None => (rest, String::new()),
|
||||
};
|
||||
|
||||
if user.is_empty() || user == "agent" {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(if tail.is_empty() {
|
||||
PathBuf::from("/home/agent")
|
||||
} else {
|
||||
PathBuf::from(format!("/home/agent/{tail}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sbx_mixin_file() -> PathBuf {
|
||||
config_dir().join(SBX_MIXIN_FILE_NAME)
|
||||
}
|
||||
|
||||
pub fn global_tools_sbx_mixin_file() -> PathBuf {
|
||||
functions_dir().join(SBX_MIXIN_FILE_NAME)
|
||||
}
|
||||
|
||||
pub fn find_workspace_sbx_mixin(start: &Path) -> Option<PathBuf> {
|
||||
for dir in start.ancestors() {
|
||||
let candidate = dir
|
||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
||||
.join(SBX_MIXIN_FILE_NAME);
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn oauth_tokens_path() -> PathBuf {
|
||||
@@ -47,6 +138,26 @@ pub fn log_path() -> PathBuf {
|
||||
cache_path().join(format!("{}.log", env!("CARGO_CRATE_NAME")))
|
||||
}
|
||||
|
||||
pub fn sbx_kit_dir() -> PathBuf {
|
||||
cache_path().join(SBX_KIT_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn sbx_kit_hash_file() -> PathBuf {
|
||||
sbx_kit_dir().join(SBX_KIT_HASH_FILE)
|
||||
}
|
||||
|
||||
pub fn sbx_vault_mixins_dir() -> PathBuf {
|
||||
cache_path().join(SBX_VAULT_MIXINS_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn sbx_vault_mixins_hash_file() -> PathBuf {
|
||||
sbx_vault_mixins_dir().join(SBX_KIT_HASH_FILE)
|
||||
}
|
||||
|
||||
pub fn sbx_mixin_kits_dir() -> PathBuf {
|
||||
cache_path().join(SBX_MIXIN_KITS_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn config_file() -> PathBuf {
|
||||
match env::var(get_env_name("config_file")) {
|
||||
Ok(value) => PathBuf::from(value),
|
||||
@@ -195,6 +306,20 @@ pub fn models_override_file() -> PathBuf {
|
||||
local_path("models-override.yaml")
|
||||
}
|
||||
|
||||
pub fn global_memory_dir() -> PathBuf {
|
||||
config_dir().join(MEMORY_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn global_memory_index_path() -> PathBuf {
|
||||
global_memory_dir().join(MEMORY_INDEX_FILE_NAME)
|
||||
}
|
||||
|
||||
pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf {
|
||||
workspace_root
|
||||
.join(WORKSPACE_MEMORY_DIR_NAME)
|
||||
.join(MEMORY_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn log_config() -> Result<(LevelFilter, Option<PathBuf>)> {
|
||||
let log_level = env::var(get_env_name("log_level"))
|
||||
.ok()
|
||||
@@ -350,6 +475,197 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod sandbox_home_translation {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
fn with_sandbox<F: FnOnce()>(f: F) {
|
||||
let prev = env::var_os("IS_SANDBOX");
|
||||
unsafe {
|
||||
env::set_var("IS_SANDBOX", "1");
|
||||
}
|
||||
f();
|
||||
unsafe {
|
||||
match prev {
|
||||
Some(v) => env::set_var("IS_SANDBOX", v),
|
||||
None => env::remove_var("IS_SANDBOX"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn without_sandbox<F: FnOnce()>(f: F) {
|
||||
let prev = env::var_os("IS_SANDBOX");
|
||||
unsafe {
|
||||
env::remove_var("IS_SANDBOX");
|
||||
}
|
||||
f();
|
||||
unsafe {
|
||||
if let Some(v) = prev {
|
||||
env::set_var("IS_SANDBOX", v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_when_not_in_sandbox() {
|
||||
without_sandbox(|| {
|
||||
let p = Path::new("/home/atusa/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_host_home_to_agent_home() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/home/atusa/.coyote_password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
Some(PathBuf::from("/home/agent/.coyote_password"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_nested_host_home_path() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/home/atusa/.config/coyote/.password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_when_path_already_targets_agent_home() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/home/agent/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_when_path_is_outside_home() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/etc/coyote/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_for_relative_path() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new(".coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_for_first_segment_not_home() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/opt/atusa/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_macos_users_path() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/Users/atusa/.coyote_password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
Some(PathBuf::from("/home/agent/.coyote_password"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_macos_nested_path() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/Users/atusa/.config/coyote/.password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_when_macos_path_already_targets_agent() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("/Users/agent/.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_windows_drive_letter_path() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("C:\\Users\\atusa\\.coyote_password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
Some(PathBuf::from("/home/agent/.coyote_password"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn translates_windows_nested_path() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("D:\\Users\\atusa\\.config\\coyote\\.password");
|
||||
assert_eq!(
|
||||
translate_sandboxed_home_path(p),
|
||||
Some(PathBuf::from("/home/agent/.config/coyote/.password"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_none_when_windows_path_already_targets_agent() {
|
||||
with_sandbox(|| {
|
||||
let p = Path::new("C:\\Users\\agent\\.coyote_password");
|
||||
assert_eq!(translate_sandboxed_home_path(p), None);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_kit_override_reflects_env_var_state() {
|
||||
let env_name = get_env_name("sandbox_kit");
|
||||
let prev = env::var_os(&env_name);
|
||||
|
||||
unsafe {
|
||||
env::remove_var(&env_name);
|
||||
}
|
||||
assert_eq!(sandbox_kit_override(), None);
|
||||
|
||||
let probe = PathBuf::from("/tmp/coyote-sandbox-kit-probe");
|
||||
unsafe {
|
||||
env::set_var(&env_name, &probe);
|
||||
}
|
||||
assert_eq!(sandbox_kit_override(), Some(probe));
|
||||
|
||||
unsafe {
|
||||
match prev {
|
||||
Some(v) => env::set_var(&env_name, v),
|
||||
None => env::remove_var(&env_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_skills_skips_invalid_directory_names() {
|
||||
let unique = time::SystemTime::now()
|
||||
|
||||
@@ -8,6 +8,43 @@ pub(crate) const DEFAULT_SKILL_INSTRUCTIONS: &str = indoc! {"
|
||||
complete to keep the context lean."
|
||||
};
|
||||
|
||||
pub(crate) const DEFAULT_MEMORY_INSTRUCTIONS: &str = indoc! {"
|
||||
## Memory
|
||||
A persistent memory file system survives across sessions. The MEMORY.md content shown above is
|
||||
your always-on context (universal facts, hard rules, binding feedback). Drill files hold deeper,
|
||||
on-demand context that you fetch with `memory__read`.
|
||||
|
||||
Tools:
|
||||
- `memory__read(name)`: Read a specific drill file's full content.
|
||||
- `memory__write(name, content, scope)`: Create or replace a drill file (scope: 'global' | 'workspace').
|
||||
The MEMORY.md index is appended automatically; do not also update the index by hand.
|
||||
- `memory__edit_index(scope, content)`: Replace the entire MEMORY.md at the given scope.
|
||||
Use this to add always-on facts, reorganize, prune stale entries, or fix descriptions.
|
||||
- `memory__list()`: See all known drill files and their metadata.
|
||||
- `memory__lint()`: Health-check memory for orphans, broken links, oversized files.
|
||||
|
||||
RULES:
|
||||
- Every interaction has two outputs: your answer AND any memory updates the conversation warrants.
|
||||
Don't let learnings evaporate into chat history.
|
||||
- All MEMORY.md edits MUST go through `memory__edit_index`. NEVER use `fs_write`, `fs_patch`,
|
||||
or any other generic file tool on MEMORY.md — Coyote manages its location and a stray
|
||||
MEMORY.md outside the managed path is invisible to memory.
|
||||
- All drill files MUST go through `memory__write`. The index updates itself.
|
||||
- Use [[wikilink]] notation in memory files to reference other memories by their `name:` slug.
|
||||
- NEVER write secrets, credentials, or API keys to memory — memory is plaintext on disk.
|
||||
Use coyote's Vault for secrets.
|
||||
- Keep individual drill files focused (under ~2K chars). Split large topics across linked files."
|
||||
};
|
||||
|
||||
pub(crate) const DEFAULT_MEMORY_INSTRUCTIONS_READONLY: &str = indoc! {"
|
||||
## Memory (read-only)
|
||||
The memory content shown above persists across sessions. In this session it is READ-ONLY — the user
|
||||
maintains memory files manually outside the conversation.
|
||||
|
||||
Reference the memory content as authoritative context about the user and their workspace.
|
||||
Do not propose writing to memory or call any `memory__*` tools — they are unavailable."
|
||||
};
|
||||
|
||||
pub(in crate::config) const DEFAULT_TODO_INSTRUCTIONS: &str = indoc! {"
|
||||
## Task Tracking
|
||||
You have built-in task tracking tools. Use them to track your progress:
|
||||
@@ -62,6 +99,36 @@ pub(in crate::config) const DEFAULT_SPAWN_INSTRUCTIONS: &str = indoc! {"
|
||||
agent__collect --id agent_explore_e5f6g7h8
|
||||
```
|
||||
|
||||
### CRITICAL: Never end your turn with pending agents
|
||||
|
||||
Spawned agents do NOT report back on their own. They run in the background until you
|
||||
actively reclaim them with `agent__collect` (to get their output) or `agent__cancel`
|
||||
(to discard them). If you spawn agents and then emit a final message without reclaiming
|
||||
them, the system will detect the unreclaimed agents and reject the turn-end, injecting
|
||||
a reminder forcing you to handle them. After several such reminders, the system will
|
||||
auto-cancel them and warn you that work was lost.
|
||||
|
||||
The correct flow when you have nothing else to do:
|
||||
|
||||
```
|
||||
# WRONG - do NOT do this:
|
||||
agent__spawn --agent explore --prompt \"...\"
|
||||
agent__spawn --agent explore --prompt \"...\"
|
||||
# ... emit text like \"I will synthesize once they report back.\" and stop
|
||||
# ^ The agents will be abandoned. Their output will be lost.
|
||||
|
||||
# RIGHT - always do this:
|
||||
agent__spawn --agent explore --prompt \"...\"
|
||||
agent__spawn --agent explore --prompt \"...\"
|
||||
agent__collect --id <first_id> # blocks until done
|
||||
agent__collect --id <second_id> # blocks until done
|
||||
# ... NOW you can synthesize and end your turn
|
||||
```
|
||||
|
||||
`agent__collect` is a **blocking wait**: it pauses your execution until the agent
|
||||
completes, then returns the output as a tool result. Use it freely — it is the
|
||||
correct primitive for \"I'm done with my own work and just need the agents' results\".
|
||||
|
||||
### Parallel Spawning (DEFAULT for multi-agent work)
|
||||
|
||||
When a task needs multiple agents, **spawn them all at once**, then collect:
|
||||
|
||||
+512
-13
@@ -9,7 +9,8 @@ use super::{
|
||||
AGENTS_DIR_NAME, Agent, AgentVariables, AppConfig, AppState, AssetCategory, CREATE_TITLE_ROLE,
|
||||
Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, RIGHT_PROMPT, Role,
|
||||
RoleLike, SESSIONS_DIR_NAME, SUMMARIZATION_PROMPT, SUMMARY_CONTEXT_PROMPT, StateFlags,
|
||||
TEMP_ROLE_NAME, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, list_agents, paths,
|
||||
TEMP_ROLE_NAME, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, list_agents, memory,
|
||||
paths,
|
||||
};
|
||||
use super::{MessageContentToolCalls, prompts};
|
||||
use crate::client::{Model, ModelType, list_models};
|
||||
@@ -30,6 +31,9 @@ use crate::utils::{
|
||||
list_file_names, now, render_prompt, temp_file,
|
||||
};
|
||||
|
||||
use super::memory::{
|
||||
DEFAULT_MEMORY_CAP_WITH_TOOLS, DEFAULT_MEMORY_CAP_WITHOUT_TOOLS, MemoryStore, WorkspaceMemory,
|
||||
};
|
||||
use crate::graph;
|
||||
use anyhow::{Context, Error, Result, bail};
|
||||
use gman::providers::SupportedProvider;
|
||||
@@ -59,6 +63,21 @@ pub struct SkillInstructionsConfig {
|
||||
pub instructions: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MemoryConfig {
|
||||
pub enabled: bool,
|
||||
pub workspace: Option<WorkspaceMemory>,
|
||||
}
|
||||
|
||||
impl MemoryConfig {
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
workspace: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Must stay in sync with the predicate that registers `skill__*` tools in `rebuild_tool_scope`
|
||||
/// (and in `graph::llm::run_llm_node`). Telling the model to call tools that are not exposed
|
||||
/// is a footgun. `compatible_enabled` is the post-filter universe that `skill__list` would
|
||||
@@ -101,6 +120,7 @@ pub struct RequestContext {
|
||||
pub escalation_queue: Option<Arc<EscalationQueue>>,
|
||||
pub current_depth: usize,
|
||||
pub auto_continue_count: usize,
|
||||
pub pending_agents_guardrail_count: u32,
|
||||
pub todo_list: TodoList,
|
||||
pub skill_registry: SkillRegistry,
|
||||
pub last_continuation_response: Option<String>,
|
||||
@@ -130,6 +150,7 @@ impl RequestContext {
|
||||
escalation_queue: None,
|
||||
current_depth: 0,
|
||||
auto_continue_count: 0,
|
||||
pending_agents_guardrail_count: 0,
|
||||
todo_list: TodoList::default(),
|
||||
skill_registry: SkillRegistry::default(),
|
||||
last_continuation_response: None,
|
||||
@@ -185,6 +206,7 @@ impl RequestContext {
|
||||
escalation_queue: None,
|
||||
current_depth: 0,
|
||||
auto_continue_count: 0,
|
||||
pending_agents_guardrail_count: 0,
|
||||
todo_list: TodoList::default(),
|
||||
skill_registry: SkillRegistry::default(),
|
||||
last_continuation_response: None,
|
||||
@@ -227,6 +249,7 @@ impl RequestContext {
|
||||
escalation_queue: self.escalation_queue.clone(),
|
||||
current_depth: self.current_depth,
|
||||
auto_continue_count: 0,
|
||||
pending_agents_guardrail_count: 0,
|
||||
todo_list: self.todo_list.clone(),
|
||||
skill_registry: self.skill_registry.clone(),
|
||||
last_continuation_response: None,
|
||||
@@ -267,6 +290,7 @@ impl RequestContext {
|
||||
escalation_queue: parent.escalation_queue.clone(),
|
||||
current_depth,
|
||||
auto_continue_count: 0,
|
||||
pending_agents_guardrail_count: 0,
|
||||
todo_list: TodoList::default(),
|
||||
skill_registry: SkillRegistry::default(),
|
||||
last_continuation_response: None,
|
||||
@@ -347,9 +371,32 @@ impl RequestContext {
|
||||
if self.rag.is_some() {
|
||||
flags |= StateFlags::RAG;
|
||||
}
|
||||
if self.app.config.function_calling_support {
|
||||
flags |= StateFlags::FUNCTION_CALLING;
|
||||
}
|
||||
if self.auto_continue_config().enabled {
|
||||
flags |= StateFlags::AUTO_CONTINUE;
|
||||
}
|
||||
if self.resolved_skills_enabled() {
|
||||
flags |= StateFlags::SKILLS_ENABLED;
|
||||
}
|
||||
flags
|
||||
}
|
||||
|
||||
pub fn resolved_skills_enabled(&self) -> bool {
|
||||
if let Some(agent) = &self.agent
|
||||
&& let Some(value) = agent.skills_enabled()
|
||||
{
|
||||
return value;
|
||||
}
|
||||
let app = &self.app.config;
|
||||
self.session
|
||||
.as_ref()
|
||||
.and_then(|s| s.skills_enabled())
|
||||
.or_else(|| self.role.as_ref().and_then(|r| r.skills_enabled()))
|
||||
.unwrap_or(app.skills_enabled)
|
||||
}
|
||||
|
||||
pub fn messages_file(&self) -> PathBuf {
|
||||
match &self.agent {
|
||||
None => match env::var(get_env_name("messages_file")) {
|
||||
@@ -426,6 +473,50 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn todo_info(&self) -> Result<String> {
|
||||
if !self.auto_continue_config().enabled {
|
||||
bail!(
|
||||
"Auto-continuation is disabled. Enable it by setting `auto_continue: true` in your config or running `.set auto_continue true`."
|
||||
);
|
||||
}
|
||||
|
||||
if self.todo_list.is_empty() {
|
||||
return Ok("No todos in the running list.\n".to_string());
|
||||
}
|
||||
|
||||
let mut out = self.todo_list.render_for_model();
|
||||
out.push('\n');
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn tools_info(&self) -> Result<String> {
|
||||
if !self.app.config.function_calling_support {
|
||||
bail!(
|
||||
"Function calling is disabled. Enable it by setting `function_calling_support: true` in your config or running `.set function_calling_support true`."
|
||||
);
|
||||
}
|
||||
let role = self.extract_role(&self.app.config)?;
|
||||
match self.select_functions(&role) {
|
||||
None => Ok("No tools enabled for the next request.\n".to_string()),
|
||||
Some(functions) => {
|
||||
let mut names: Vec<&str> = functions.iter().map(|f| f.name.as_str()).collect();
|
||||
names.sort_unstable();
|
||||
let mut out = format!(
|
||||
"Tools enabled for the next request: {}\n\n",
|
||||
functions.len()
|
||||
);
|
||||
|
||||
for name in names {
|
||||
out.push_str(" ");
|
||||
out.push_str(name);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_sessions(&self) -> Vec<String> {
|
||||
list_file_names(self.sessions_dir(), ".yaml")
|
||||
}
|
||||
@@ -666,6 +757,37 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
let memory_config = self.memory_config();
|
||||
if memory_config.enabled {
|
||||
let store = MemoryStore {
|
||||
global_dir: paths::global_memory_dir(),
|
||||
workspace: memory_config.workspace,
|
||||
};
|
||||
let with_tools = app.function_calling_support;
|
||||
let cap = if with_tools {
|
||||
app.memory_cap_with_tools
|
||||
.unwrap_or(DEFAULT_MEMORY_CAP_WITH_TOOLS)
|
||||
} else {
|
||||
app.memory_cap_without_tools
|
||||
.unwrap_or(DEFAULT_MEMORY_CAP_WITHOUT_TOOLS)
|
||||
};
|
||||
match memory::build_memory_section(&store, with_tools, cap) {
|
||||
Ok(Some(section)) => {
|
||||
let separator = if role.is_empty_prompt() { "" } else { "\n\n" };
|
||||
role.append_to_prompt(separator);
|
||||
role.append_to_prompt(§ion);
|
||||
role.append_to_prompt("\n\n");
|
||||
role.append_to_prompt(if with_tools {
|
||||
prompts::DEFAULT_MEMORY_INSTRUCTIONS
|
||||
} else {
|
||||
prompts::DEFAULT_MEMORY_INSTRUCTIONS_READONLY
|
||||
});
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => warn!("memory injection failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(self.skill_registry.effective_role(&role, &policy))
|
||||
}
|
||||
|
||||
@@ -705,6 +827,52 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn memory_config(&self) -> MemoryConfig {
|
||||
if let Some(agent) = &self.agent
|
||||
&& graph::agent_has_graph(agent.name())
|
||||
{
|
||||
return MemoryConfig::disabled();
|
||||
}
|
||||
|
||||
let agent_pref = self.agent.as_ref().and_then(|a| a.memory());
|
||||
let session_pref = self.session.as_ref().and_then(|s| s.memory());
|
||||
let role_pref = self.role.as_ref().and_then(|r| r.memory());
|
||||
let app_pref = self.app.config.memory;
|
||||
|
||||
let resolved = agent_pref
|
||||
.or(session_pref)
|
||||
.or(role_pref)
|
||||
.or(app_pref)
|
||||
.unwrap_or(true);
|
||||
if !resolved {
|
||||
return MemoryConfig::disabled();
|
||||
}
|
||||
|
||||
let cwd = env::current_dir().ok();
|
||||
let store = cwd.as_deref().map(MemoryStore::new);
|
||||
let workspace = store.as_ref().and_then(|s| s.workspace.clone());
|
||||
|
||||
let global_exists = paths::global_memory_index_path().exists();
|
||||
let workspace_exists = workspace.is_some();
|
||||
|
||||
if !global_exists && !workspace_exists {
|
||||
return MemoryConfig::disabled();
|
||||
}
|
||||
|
||||
MemoryConfig {
|
||||
enabled: true,
|
||||
workspace,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_inject_memory(&self) -> bool {
|
||||
self.memory_config().enabled
|
||||
}
|
||||
|
||||
pub fn should_register_memory_tools(&self) -> bool {
|
||||
self.should_inject_memory() && self.app.config.function_calling_support
|
||||
}
|
||||
|
||||
pub fn auto_continue_config(&self) -> AutoContinueConfig {
|
||||
if let Some(agent) = &self.agent {
|
||||
return AutoContinueConfig {
|
||||
@@ -935,6 +1103,10 @@ impl RequestContext {
|
||||
"enabled_mcp_servers",
|
||||
super::format_option_value(&role.enabled_mcp_servers().map(|v| v.join(","))),
|
||||
),
|
||||
(
|
||||
"enabled_skills",
|
||||
super::format_option_value(&role.enabled_skills().map(|v| v.join(","))),
|
||||
),
|
||||
(
|
||||
"max_output_tokens",
|
||||
role.model()
|
||||
@@ -950,6 +1122,15 @@ impl RequestContext {
|
||||
"compression_threshold",
|
||||
app.compression_threshold.to_string(),
|
||||
),
|
||||
("memory", super::format_option_value(&app.memory)),
|
||||
(
|
||||
"memory_cap_with_tools",
|
||||
super::format_option_value(&app.memory_cap_with_tools),
|
||||
),
|
||||
(
|
||||
"memory_cap_without_tools",
|
||||
super::format_option_value(&app.memory_cap_without_tools),
|
||||
),
|
||||
(
|
||||
"rag_reranker_model",
|
||||
super::format_option_value(&rag_reranker_model),
|
||||
@@ -961,6 +1142,7 @@ impl RequestContext {
|
||||
app.function_calling_support.to_string(),
|
||||
),
|
||||
("mcp_server_support", app.mcp_server_support.to_string()),
|
||||
("skills_enabled", app.skills_enabled.to_string()),
|
||||
("auto_continue", app.auto_continue.to_string()),
|
||||
("max_auto_continues", app.max_auto_continues.to_string()),
|
||||
("stream", app.stream.to_string()),
|
||||
@@ -976,9 +1158,11 @@ impl RequestContext {
|
||||
("roles_dir", display_path(&paths::roles_dir())),
|
||||
("skills_dir", display_path(&paths::skills_dir())),
|
||||
("sessions_dir", display_path(&self.sessions_dir())),
|
||||
("memory_dir", display_path(&paths::global_memory_dir())),
|
||||
("rags_dir", display_path(&paths::rags_dir())),
|
||||
("macros_dir", display_path(&paths::macros_dir())),
|
||||
("functions_dir", display_path(&paths::functions_dir())),
|
||||
("sbx_kit_dir", display_path(&paths::sbx_kit_dir())),
|
||||
("messages_file", display_path(&self.messages_file())),
|
||||
];
|
||||
|
||||
@@ -1836,6 +2020,7 @@ impl RequestContext {
|
||||
} else {
|
||||
self.update_app_config(|app| app.skills_enabled = value.unwrap_or(true));
|
||||
}
|
||||
self.refresh_tool_scope(abort_signal.clone()).await?;
|
||||
}
|
||||
"enabled_mcp_servers" => {
|
||||
let raw: Option<String> = super::parse_value(value)?;
|
||||
@@ -1945,11 +2130,15 @@ impl RequestContext {
|
||||
} else {
|
||||
self.update_app_config(|app| app.auto_continue = value);
|
||||
}
|
||||
if value
|
||||
let should_register = self.agent.is_none()
|
||||
&& self.app.config.function_calling_support
|
||||
&& !self.tool_scope.functions.contains("todo__init")
|
||||
{
|
||||
&& self.auto_continue_config().enabled;
|
||||
let already_registered = self.tool_scope.functions.contains("todo__init");
|
||||
|
||||
if should_register && !already_registered {
|
||||
self.tool_scope.functions.append_todo_functions();
|
||||
} else if !should_register && already_registered {
|
||||
self.tool_scope.functions.remove_todo_functions();
|
||||
}
|
||||
}
|
||||
"max_auto_continues" => {
|
||||
@@ -1992,6 +2181,24 @@ impl RequestContext {
|
||||
self.update_app_config(|app| app.skill_instructions = value);
|
||||
}
|
||||
}
|
||||
"memory" => {
|
||||
let value: bool = value.parse().with_context(|| "Invalid value")?;
|
||||
|
||||
if let Some(session) = self.session.as_mut() {
|
||||
session.set_memory(Some(value));
|
||||
} else {
|
||||
self.update_app_config(|app| app.memory = Some(value));
|
||||
}
|
||||
|
||||
let should_register = self.should_register_memory_tools();
|
||||
let already_registered = self.tool_scope.functions.contains("memory__read");
|
||||
|
||||
if should_register && !already_registered {
|
||||
self.tool_scope.functions.append_memory_functions();
|
||||
} else if !should_register && already_registered {
|
||||
self.tool_scope.functions.remove_memory_functions();
|
||||
}
|
||||
}
|
||||
_ => bail!("Unknown key '{key}'"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -2068,11 +2275,6 @@ impl RequestContext {
|
||||
super::map_completion_values(values)
|
||||
}
|
||||
".macro" => super::map_completion_values(paths::list_macros()),
|
||||
".skill" => super::map_completion_values(vec![
|
||||
"loaded".to_string(),
|
||||
"load".to_string(),
|
||||
"unload".to_string(),
|
||||
]),
|
||||
".starter" => match &self.agent {
|
||||
Some(agent) => agent
|
||||
.conversation_starters()
|
||||
@@ -2094,6 +2296,7 @@ impl RequestContext {
|
||||
"inject_skill_instructions",
|
||||
"skill_instructions",
|
||||
"max_auto_continues",
|
||||
"memory",
|
||||
"save_session",
|
||||
"compression_threshold",
|
||||
"rag_reranker_model",
|
||||
@@ -2264,10 +2467,11 @@ impl RequestContext {
|
||||
super::complete_bool(config.inject)
|
||||
}
|
||||
"skill_instructions" => vec!["null".to_string()],
|
||||
"memory" => super::complete_bool(self.should_inject_memory()),
|
||||
_ => vec![],
|
||||
};
|
||||
values = candidates.into_iter().map(|v| (v, None)).collect();
|
||||
} else if cmd == ".vault" && args.len() == 2 {
|
||||
} else if cmd == ".vault" && args.len() == 2 && args[0] != "list" {
|
||||
values = self
|
||||
.app
|
||||
.vault
|
||||
@@ -2396,6 +2600,9 @@ impl RequestContext {
|
||||
if app.function_calling_support && policy.skills_enabled {
|
||||
functions.append_skill_functions();
|
||||
}
|
||||
if self.should_register_memory_tools() {
|
||||
functions.append_memory_functions();
|
||||
}
|
||||
|
||||
let tool_tracker = self.tool_scope.tool_tracker.clone();
|
||||
self.tool_scope = ToolScope {
|
||||
@@ -2655,7 +2862,7 @@ impl RequestContext {
|
||||
|
||||
if self.agent.take().is_some() {
|
||||
if let Some(supervisor) = self.supervisor.clone() {
|
||||
supervisor.read().cancel_all();
|
||||
supervisor.read().cancel_recursive();
|
||||
}
|
||||
self.supervisor = None;
|
||||
self.parent_supervisor = None;
|
||||
@@ -2664,6 +2871,7 @@ impl RequestContext {
|
||||
self.escalation_queue = None;
|
||||
self.current_depth = 0;
|
||||
self.auto_continue_count = 0;
|
||||
self.pending_agents_guardrail_count = 0;
|
||||
self.todo_list = TodoList::default();
|
||||
self.rag.take();
|
||||
self.discontinuous_last_message();
|
||||
@@ -3163,6 +3371,46 @@ mod tests {
|
||||
assert!(!Arc::ptr_eq(&ctx.app.config, &previous));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_config_app_some_false_disables_via_cascade() {
|
||||
let mut ctx = create_test_ctx();
|
||||
|
||||
ctx.update_app_config(|app| app.memory = Some(false));
|
||||
|
||||
assert!(
|
||||
!ctx.should_inject_memory(),
|
||||
"AppConfig.memory=Some(false) must disable memory regardless of on-disk content (this is the --no-memory CLI path)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_config_role_false_beats_app_true_in_cascade() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.update_app_config(|app| app.memory = Some(true));
|
||||
let role = Role::new("memory_off_role", "---\nmemory: false\n---\n");
|
||||
assert_eq!(role.memory(), Some(false), "metadata parser sanity check");
|
||||
ctx.role = Some(role);
|
||||
assert!(
|
||||
!ctx.should_inject_memory(),
|
||||
"Role::memory=Some(false) must win over AppConfig::memory=Some(true)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_register_memory_tools_false_when_function_calling_off() {
|
||||
let mut ctx = create_test_ctx();
|
||||
|
||||
ctx.update_app_config(|app| {
|
||||
app.memory = Some(true);
|
||||
app.function_calling_support = false;
|
||||
});
|
||||
|
||||
assert!(
|
||||
!ctx.should_register_memory_tools(),
|
||||
"memory tools must require function_calling_support even when memory itself would otherwise be enabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_role_obj_sets_role() {
|
||||
let mut ctx = create_test_ctx();
|
||||
@@ -3579,6 +3827,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn update_skills_enabled_false_removes_skill_meta_tools_from_scope() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let app_state = app_state_with_mcp_config(false, &[]);
|
||||
let mut ctx = RequestContext::new(app_state, WorkingMode::Repl);
|
||||
let app = ctx.app.config.clone();
|
||||
let abort = utils::create_abort_signal();
|
||||
|
||||
run_async(ctx.rebuild_tool_scope(&app, None, abort.clone())).unwrap();
|
||||
|
||||
let names_before: Vec<String> = ctx
|
||||
.tool_scope
|
||||
.functions
|
||||
.declarations()
|
||||
.iter()
|
||||
.map(|f| f.name.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
names_before.iter().any(|n| n.starts_with("skill__")),
|
||||
"expected skill__* functions before toggle, got: {names_before:?}"
|
||||
);
|
||||
|
||||
run_async(ctx.update("skills_enabled false", abort)).unwrap();
|
||||
|
||||
let names_after: Vec<String> = ctx
|
||||
.tool_scope
|
||||
.functions
|
||||
.declarations()
|
||||
.iter()
|
||||
.map(|f| f.name.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
!names_after.iter().any(|n| n.starts_with("skill__")),
|
||||
"expected skill__* functions to be removed after `.set skills_enabled false`, got: {names_after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_functions_returns_none_when_no_tools_enabled() {
|
||||
let ctx = create_test_ctx();
|
||||
@@ -3878,9 +4164,84 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_empty_context() {
|
||||
fn state_empty_context_has_no_context_flags() {
|
||||
let ctx = create_test_ctx();
|
||||
assert_eq!(ctx.state(), StateFlags::empty());
|
||||
|
||||
let state = ctx.state();
|
||||
|
||||
assert!(!state.contains(StateFlags::ROLE));
|
||||
assert!(!state.contains(StateFlags::SESSION));
|
||||
assert!(!state.contains(StateFlags::SESSION_EMPTY));
|
||||
assert!(!state.contains(StateFlags::AGENT));
|
||||
assert!(!state.contains(StateFlags::RAG));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_includes_function_calling_when_app_enables_it() {
|
||||
let ctx = create_test_ctx();
|
||||
|
||||
assert!(ctx.state().contains(StateFlags::FUNCTION_CALLING));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_includes_skills_enabled_when_app_enables_it() {
|
||||
let ctx = create_test_ctx();
|
||||
|
||||
assert!(ctx.state().contains(StateFlags::SKILLS_ENABLED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_omits_skills_enabled_when_app_disables_it() {
|
||||
let mut ctx = create_test_ctx();
|
||||
|
||||
ctx.update_app_config(|app| app.skills_enabled = false);
|
||||
|
||||
assert!(!ctx.state().contains(StateFlags::SKILLS_ENABLED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_skills_enabled_respects_session_override() {
|
||||
let mut ctx = create_test_ctx();
|
||||
let mut session = Session::default();
|
||||
session.set_skills_enabled(Some(false));
|
||||
|
||||
ctx.session = Some(session);
|
||||
|
||||
assert!(!ctx.state().contains(StateFlags::SKILLS_ENABLED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_skills_enabled_respects_role_override() {
|
||||
let mut ctx = create_test_ctx();
|
||||
let role = Role::new("r", "---\nskills_enabled: false\n---\nbody");
|
||||
|
||||
ctx.role = Some(role);
|
||||
|
||||
assert!(!ctx.state().contains(StateFlags::SKILLS_ENABLED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_omits_function_calling_when_app_disables_it() {
|
||||
let app_state = {
|
||||
let config = AppConfig {
|
||||
function_calling_support: false,
|
||||
..AppConfig::default()
|
||||
};
|
||||
Arc::new(AppState {
|
||||
config: Arc::new(config),
|
||||
vault: Arc::new(Vault::default()),
|
||||
mcp_factory: Arc::new(McpFactory::default()),
|
||||
rag_cache: Arc::new(RagCache::default()),
|
||||
mcp_config: None,
|
||||
mcp_log_path: None,
|
||||
mcp_registry: None,
|
||||
functions: Functions::default(),
|
||||
})
|
||||
};
|
||||
|
||||
let ctx = RequestContext::new(app_state, WorkingMode::Cmd);
|
||||
|
||||
assert!(!ctx.state().contains(StateFlags::FUNCTION_CALLING));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3908,6 +4269,144 @@ mod tests {
|
||||
assert!(state.contains(StateFlags::SESSION_EMPTY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_info_errors_when_auto_continue_disabled() {
|
||||
let ctx = create_test_ctx();
|
||||
let err = ctx.todo_info().unwrap_err();
|
||||
|
||||
let msg = err.to_string();
|
||||
|
||||
assert!(
|
||||
msg.contains("Auto-continuation is disabled"),
|
||||
"expected error to mention auto-continuation, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_info_returns_empty_message_when_list_is_empty() {
|
||||
let mut ctx = create_test_ctx();
|
||||
|
||||
ctx.update_app_config(|app| app.auto_continue = true);
|
||||
|
||||
let info = ctx.todo_info().unwrap();
|
||||
assert!(
|
||||
info.contains("No todos in the running list"),
|
||||
"expected 'No todos' message, got: {info}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn todo_info_renders_running_list() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.update_app_config(|app| app.auto_continue = true);
|
||||
ctx.init_todo_list("Map Labs");
|
||||
ctx.add_todo("Discover columns");
|
||||
ctx.add_todo("Write report");
|
||||
|
||||
ctx.mark_todo_done(1);
|
||||
|
||||
let info = ctx.todo_info().unwrap();
|
||||
assert!(
|
||||
info.contains("Goal: Map Labs"),
|
||||
"expected goal in output, got: {info}"
|
||||
);
|
||||
assert!(
|
||||
info.contains("Progress: 1/2 completed"),
|
||||
"expected progress line, got: {info}"
|
||||
);
|
||||
assert!(
|
||||
info.contains("Discover columns"),
|
||||
"expected first task, got: {info}"
|
||||
);
|
||||
assert!(
|
||||
info.contains("Write report"),
|
||||
"expected second task, got: {info}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_info_returns_message_when_no_tools_enabled() {
|
||||
let ctx = create_test_ctx();
|
||||
|
||||
let info = ctx.tools_info().unwrap();
|
||||
|
||||
assert!(
|
||||
info.contains("No tools enabled"),
|
||||
"expected 'No tools enabled' message, got: {info}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_info_lists_enabled_tool_names_alphabetically() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.tool_scope.functions.append_todo_functions();
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec!["all".to_string()]));
|
||||
ctx.role = Some(role);
|
||||
|
||||
let info = ctx.tools_info().unwrap();
|
||||
|
||||
assert!(
|
||||
info.contains("Tools enabled for the next request:"),
|
||||
"expected count line, got: {info}"
|
||||
);
|
||||
assert!(
|
||||
info.contains("todo__init"),
|
||||
"expected todo__init in output, got: {info}"
|
||||
);
|
||||
|
||||
let positions: Vec<usize> = info
|
||||
.lines()
|
||||
.filter(|line| line.trim().starts_with("todo__"))
|
||||
.enumerate()
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
assert!(
|
||||
!positions.is_empty(),
|
||||
"expected at least one todo__ entry, got: {info}"
|
||||
);
|
||||
|
||||
let todo_lines: Vec<&str> = info
|
||||
.lines()
|
||||
.filter(|line| line.trim().starts_with("todo__"))
|
||||
.collect();
|
||||
let mut sorted = todo_lines.clone();
|
||||
sorted.sort_unstable();
|
||||
assert_eq!(
|
||||
todo_lines, sorted,
|
||||
"expected todo__ entries to be alphabetically sorted, got: {todo_lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_info_errors_when_function_calling_disabled() {
|
||||
let app_state = {
|
||||
let config = AppConfig {
|
||||
function_calling_support: false,
|
||||
..AppConfig::default()
|
||||
};
|
||||
Arc::new(AppState {
|
||||
config: Arc::new(config),
|
||||
vault: Arc::new(Vault::default()),
|
||||
mcp_factory: Arc::new(McpFactory::default()),
|
||||
rag_cache: Arc::new(RagCache::default()),
|
||||
mcp_config: None,
|
||||
mcp_log_path: None,
|
||||
mcp_registry: None,
|
||||
functions: Functions::default(),
|
||||
})
|
||||
};
|
||||
let ctx = RequestContext::new(app_state, WorkingMode::Cmd);
|
||||
|
||||
let err = ctx.tools_info().unwrap_err();
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("Function calling is disabled"),
|
||||
"expected error to mention function calling, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_info_errors_when_no_role() {
|
||||
let ctx = create_test_ctx();
|
||||
|
||||
@@ -83,6 +83,8 @@ pub struct Role {
|
||||
inject_skill_instructions: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
skill_instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
memory: Option<bool>,
|
||||
|
||||
#[serde(skip)]
|
||||
model: Model,
|
||||
@@ -132,6 +134,7 @@ impl Role {
|
||||
"skill_instructions" => {
|
||||
role.skill_instructions = value.as_str().map(|v| v.to_string())
|
||||
}
|
||||
"memory" => role.memory = value.as_bool(),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
@@ -205,6 +208,9 @@ impl Role {
|
||||
if let Some(skill_instructions) = &self.skill_instructions {
|
||||
metadata.push(format!("skill_instructions: {skill_instructions}"));
|
||||
}
|
||||
if let Some(memory) = self.memory {
|
||||
metadata.push(format!("memory: {memory}"));
|
||||
}
|
||||
if metadata.is_empty() {
|
||||
format!("{}\n", self.prompt)
|
||||
} else if self.prompt.is_empty() {
|
||||
@@ -323,6 +329,10 @@ impl Role {
|
||||
self.skill_instructions.as_deref()
|
||||
}
|
||||
|
||||
pub fn memory(&self) -> Option<bool> {
|
||||
self.memory
|
||||
}
|
||||
|
||||
pub fn skills_enabled(&self) -> Option<bool> {
|
||||
self.skills_enabled
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ pub struct Session {
|
||||
inject_skill_instructions: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
skill_instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
memory: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
role_name: Option<String>,
|
||||
@@ -237,6 +239,9 @@ impl Session {
|
||||
if let Some(skill_instructions) = self.skill_instructions() {
|
||||
data["skill_instructions"] = skill_instructions.into();
|
||||
}
|
||||
if let Some(memory) = self.memory() {
|
||||
data["memory"] = memory.into();
|
||||
}
|
||||
let (tokens, percent) = self.tokens_usage();
|
||||
data["total_tokens"] = tokens.into();
|
||||
if let Some(max_input_tokens) = self.model().max_input_tokens() {
|
||||
@@ -324,6 +329,9 @@ impl Session {
|
||||
if let Some(skill_instructions) = self.skill_instructions() {
|
||||
items.push(("skill_instructions", skill_instructions.to_string()));
|
||||
}
|
||||
if let Some(memory) = self.memory() {
|
||||
items.push(("memory", memory.to_string()));
|
||||
}
|
||||
|
||||
if let Some(max_input_tokens) = self.model().max_input_tokens() {
|
||||
items.push(("max_input_tokens", max_input_tokens.to_string()));
|
||||
@@ -473,6 +481,10 @@ impl Session {
|
||||
self.skill_instructions.as_deref()
|
||||
}
|
||||
|
||||
pub fn memory(&self) -> Option<bool> {
|
||||
self.memory
|
||||
}
|
||||
|
||||
pub fn set_inject_todo_instructions(&mut self, value: Option<bool>) {
|
||||
if self.inject_todo_instructions != value {
|
||||
self.inject_todo_instructions = value;
|
||||
@@ -494,6 +506,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_memory(&mut self, value: Option<bool>) {
|
||||
if self.memory != value {
|
||||
self.memory = value;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_skill_instructions(&mut self, value: Option<String>) {
|
||||
if self.skill_instructions != value {
|
||||
self.skill_instructions = value;
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{env, fs};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use indexmap::IndexMap;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{FunctionDeclaration, JsonSchema};
|
||||
use crate::config::RequestContext;
|
||||
use crate::config::memory::{
|
||||
MemoryFile, MemoryFrontmatter, MemoryStore, WorkspaceMemory, bootstrap_workspace_memory,
|
||||
find_git_root,
|
||||
};
|
||||
use crate::config::paths;
|
||||
|
||||
pub const MEMORY_FUNCTION_PREFIX: &str = "memory__";
|
||||
|
||||
const PER_FILE_SOFT_CAP: usize = 2_000;
|
||||
|
||||
pub fn memory_function_declarations() -> Vec<FunctionDeclaration> {
|
||||
vec![
|
||||
FunctionDeclaration {
|
||||
name: format!("{MEMORY_FUNCTION_PREFIX}read"),
|
||||
description: "Read the full content of a specific memory file by its name slug."
|
||||
.to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(IndexMap::from([(
|
||||
"name".to_string(),
|
||||
JsonSchema {
|
||||
type_value: Some("string".to_string()),
|
||||
description: Some(
|
||||
"The `name:` slug of the memory file to read (from MEMORY.md index)"
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
)])),
|
||||
required: Some(vec!["name".to_string()]),
|
||||
..Default::default()
|
||||
},
|
||||
agent: false,
|
||||
},
|
||||
FunctionDeclaration {
|
||||
name: format!("{MEMORY_FUNCTION_PREFIX}write"),
|
||||
description:
|
||||
"Create or replace a memory file. Caller must also update MEMORY.md index."
|
||||
.to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(IndexMap::from([
|
||||
(
|
||||
"name".to_string(),
|
||||
JsonSchema {
|
||||
type_value: Some("string".to_string()),
|
||||
description: Some(
|
||||
"Short kebab-case slug for the file (no extension)".into(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"description".to_string(),
|
||||
JsonSchema {
|
||||
type_value: Some("string".to_string()),
|
||||
description: Some("One-line description for the MEMORY.md index".into()),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"type".to_string(),
|
||||
JsonSchema {
|
||||
type_value: Some("string".to_string()),
|
||||
description: Some(
|
||||
"Memory type: user | feedback | project | reference".into(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"content".to_string(),
|
||||
JsonSchema {
|
||||
type_value: Some("string".to_string()),
|
||||
description: Some("The full markdown body of the memory file".into()),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"scope".to_string(),
|
||||
JsonSchema {
|
||||
type_value: Some("string".to_string()),
|
||||
description: Some(
|
||||
"Where to write: 'global' (user-level) or 'workspace' (project-level)"
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
])),
|
||||
required: Some(vec![
|
||||
"name".to_string(),
|
||||
"description".to_string(),
|
||||
"content".to_string(),
|
||||
"scope".to_string(),
|
||||
]),
|
||||
..Default::default()
|
||||
},
|
||||
agent: false,
|
||||
},
|
||||
FunctionDeclaration {
|
||||
name: format!("{MEMORY_FUNCTION_PREFIX}list"),
|
||||
description: "List all known drill files with metadata (size, type, scope).".to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(IndexMap::new()),
|
||||
..Default::default()
|
||||
},
|
||||
agent: false,
|
||||
},
|
||||
FunctionDeclaration {
|
||||
name: format!("{MEMORY_FUNCTION_PREFIX}lint"),
|
||||
description: "Health-check memory: orphan files, broken [[wikilinks]], oversized files."
|
||||
.to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(IndexMap::new()),
|
||||
..Default::default()
|
||||
},
|
||||
agent: false,
|
||||
},
|
||||
FunctionDeclaration {
|
||||
name: format!("{MEMORY_FUNCTION_PREFIX}edit_index"),
|
||||
description:
|
||||
"Replace the entire MEMORY.md index at the given scope. Use to add always-on facts, \
|
||||
reorganize, prune stale entries, or fix descriptions. Coyote manages the path; \
|
||||
NEVER use fs_write or any other generic file tool on MEMORY.md."
|
||||
.to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(IndexMap::from([
|
||||
(
|
||||
"scope".to_string(),
|
||||
JsonSchema {
|
||||
type_value: Some("string".to_string()),
|
||||
description: Some(
|
||||
"Where to edit: 'global' (user-level) or 'workspace' (project-level)"
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"content".to_string(),
|
||||
JsonSchema {
|
||||
type_value: Some("string".to_string()),
|
||||
description: Some("Full new contents of MEMORY.md".into()),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
])),
|
||||
required: Some(vec!["scope".to_string(), "content".to_string()]),
|
||||
..Default::default()
|
||||
},
|
||||
agent: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value) -> Result<Value> {
|
||||
if !ctx.should_register_memory_tools() {
|
||||
bail!("Memory tools are disabled (memory off or function calling unavailable).");
|
||||
}
|
||||
|
||||
let action = cmd_name
|
||||
.strip_prefix(MEMORY_FUNCTION_PREFIX)
|
||||
.unwrap_or(cmd_name);
|
||||
let cwd = env::current_dir().context("get cwd")?;
|
||||
let store = MemoryStore::new(&cwd);
|
||||
|
||||
match action {
|
||||
"read" => {
|
||||
let name = args
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("name is required"))?;
|
||||
let file = find_file(&store, name)?
|
||||
.ok_or_else(|| anyhow!("memory file '{}' not found", name))?;
|
||||
|
||||
Ok(json!({
|
||||
"name": file.frontmatter.name,
|
||||
"type": file.frontmatter.kind,
|
||||
"content": file.body,
|
||||
}))
|
||||
}
|
||||
"list" => {
|
||||
let files = store.list_files()?;
|
||||
let entries: Vec<_> = files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
json!({
|
||||
"name": f.frontmatter.name,
|
||||
"description": f.frontmatter.description,
|
||||
"type": f.frontmatter.kind,
|
||||
"char_len": f.char_len(),
|
||||
"path": f.path.display().to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(json!({
|
||||
"files": entries,
|
||||
"global_index_exists": paths::global_memory_index_path().exists(),
|
||||
"workspace": store.workspace.as_ref().map(workspace_label),
|
||||
}))
|
||||
}
|
||||
"write" => {
|
||||
let name = arg_str(args, "name")?;
|
||||
let description = arg_str(args, "description")?;
|
||||
let content = arg_str(args, "content")?;
|
||||
let scope = arg_str(args, "scope")?;
|
||||
let kind = args.get("type").and_then(Value::as_str).map(String::from);
|
||||
|
||||
let target_dir = match scope.as_str() {
|
||||
"global" => paths::global_memory_dir(),
|
||||
"workspace" => workspace_write_dir(&store, &cwd)?,
|
||||
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other),
|
||||
};
|
||||
let file = MemoryFile {
|
||||
path: target_dir.join(format!("{name}.md")),
|
||||
frontmatter: MemoryFrontmatter {
|
||||
name: name.clone(),
|
||||
description: Some(description.clone()),
|
||||
kind,
|
||||
},
|
||||
body: content,
|
||||
};
|
||||
file.save()?;
|
||||
|
||||
let index_path = target_dir.join("MEMORY.md");
|
||||
let index_updated = ensure_index_entry(&index_path, &name, &description)?;
|
||||
|
||||
Ok(json!({
|
||||
"status": "ok",
|
||||
"path": file.path.display().to_string(),
|
||||
"index_path": index_path.display().to_string(),
|
||||
"index_updated": index_updated,
|
||||
}))
|
||||
}
|
||||
"edit_index" => {
|
||||
let scope = arg_str(args, "scope")?;
|
||||
let content = arg_str(args, "content")?;
|
||||
let target_dir = match scope.as_str() {
|
||||
"global" => paths::global_memory_dir(),
|
||||
"workspace" => workspace_write_dir(&store, &cwd)?,
|
||||
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other),
|
||||
};
|
||||
let index_path = write_memory_index(&target_dir, &content)?;
|
||||
|
||||
Ok(json!({
|
||||
"status": "ok",
|
||||
"path": index_path.display().to_string(),
|
||||
}))
|
||||
}
|
||||
"lint" => lint_memory(&store),
|
||||
_ => bail!("unknown memory action: {action}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_memory_index(target_dir: &Path, content: &str) -> Result<PathBuf> {
|
||||
fs::create_dir_all(target_dir)?;
|
||||
let index_path = target_dir.join("MEMORY.md");
|
||||
fs::write(&index_path, content)?;
|
||||
Ok(index_path)
|
||||
}
|
||||
|
||||
fn ensure_index_entry(index_path: &Path, name: &str, description: &str) -> Result<bool> {
|
||||
let existing = fs::read_to_string(index_path).unwrap_or_default();
|
||||
let already_referenced =
|
||||
existing.contains(&format!("[[{name}]]")) || existing.contains(&format!("{name}.md"));
|
||||
|
||||
if already_referenced {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let entry = format!("- [[{name}]]: {description}\n");
|
||||
let new_content = if existing.is_empty() {
|
||||
format!("# Memory Index\n\n{entry}")
|
||||
} else if existing.ends_with('\n') {
|
||||
format!("{existing}{entry}")
|
||||
} else {
|
||||
format!("{existing}\n{entry}")
|
||||
};
|
||||
|
||||
if let Some(parent) = index_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
fs::write(index_path, new_content)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn arg_str(args: &Value, key: &str) -> Result<String> {
|
||||
args.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(String::from)
|
||||
.ok_or_else(|| anyhow!("{} is required", key))
|
||||
}
|
||||
|
||||
fn find_file(store: &MemoryStore, name: &str) -> Result<Option<MemoryFile>> {
|
||||
Ok(store
|
||||
.list_files()?
|
||||
.into_iter()
|
||||
.find(|f| f.frontmatter.name == name))
|
||||
}
|
||||
|
||||
fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result<PathBuf> {
|
||||
match &store.workspace {
|
||||
Some(WorkspaceMemory::Structured { dir, .. }) => Ok(dir.clone()),
|
||||
Some(WorkspaceMemory::Lite { workspace_root, .. }) => {
|
||||
Ok(paths::workspace_memory_dir_for(workspace_root))
|
||||
}
|
||||
None => match find_git_root(cwd) {
|
||||
Some(git_root) => bootstrap_workspace_memory(&git_root),
|
||||
None => bail!(
|
||||
"no workspace memory discoverable and not inside a git repository for auto-bootstrap. \
|
||||
If you want workspace memory, run `coyote --init-memory workspace`."
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_label(w: &WorkspaceMemory) -> Value {
|
||||
match w {
|
||||
WorkspaceMemory::Structured { workspace_root, .. } => json!({
|
||||
"mode": "structured",
|
||||
"root": workspace_root.display().to_string(),
|
||||
}),
|
||||
WorkspaceMemory::Lite {
|
||||
workspace_root,
|
||||
file,
|
||||
} => json!({
|
||||
"mode": "lite",
|
||||
"root": workspace_root.display().to_string(),
|
||||
"file": file.display().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn lint_memory(store: &MemoryStore) -> Result<Value> {
|
||||
let files = store.list_files()?;
|
||||
let names: HashSet<&str> = files.iter().map(|f| f.frontmatter.name.as_str()).collect();
|
||||
|
||||
let mut oversized = Vec::new();
|
||||
let mut broken_links = Vec::new();
|
||||
for f in &files {
|
||||
if f.char_len() > PER_FILE_SOFT_CAP {
|
||||
oversized.push(json!({"name": &f.frontmatter.name, "chars": f.char_len()}));
|
||||
}
|
||||
for link in extract_wikilinks(&f.body) {
|
||||
if !names.contains(link.as_str()) {
|
||||
broken_links.push(json!({"from": &f.frontmatter.name, "to": link}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let index_content = store
|
||||
.load_global_index()?
|
||||
.or_else(|| store.load_workspace_index().ok().flatten())
|
||||
.unwrap_or_default();
|
||||
let mut orphans = Vec::new();
|
||||
for f in &files {
|
||||
if !index_content.contains(&f.frontmatter.name) {
|
||||
orphans.push(f.frontmatter.name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"total_files": files.len(),
|
||||
"oversized": oversized,
|
||||
"broken_wikilinks": broken_links,
|
||||
"orphans": orphans,
|
||||
}))
|
||||
}
|
||||
|
||||
fn extract_wikilinks(body: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let bytes = body.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + 1 < bytes.len() {
|
||||
if bytes[i] == b'['
|
||||
&& bytes[i + 1] == b'['
|
||||
&& let Some(end_rel) = body[i + 2..].find("]]")
|
||||
{
|
||||
out.push(body[i + 2..i + 2 + end_rel].to_string());
|
||||
i = i + 2 + end_rel + 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::memory::discover_workspace_memory;
|
||||
use std::fs;
|
||||
use std::time;
|
||||
|
||||
fn temp_root(label: &str) -> PathBuf {
|
||||
let unique = time::SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let root = env::temp_dir().join(format!("coyote-function-memory-{label}-{unique}"));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_wikilinks_finds_all_pairs() {
|
||||
let body = "see [[alpha]] and [[bravo]] but not [single] or [[unclosed";
|
||||
|
||||
assert_eq!(
|
||||
extract_wikilinks(body),
|
||||
vec!["alpha".to_string(), "bravo".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_wikilinks_handles_empty_and_no_links() {
|
||||
assert!(extract_wikilinks("").is_empty());
|
||||
assert!(extract_wikilinks("nothing here").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_index_entry_appends_when_missing() {
|
||||
let root = temp_root("index_append");
|
||||
let index = root.join("MEMORY.md");
|
||||
fs::write(&index, "# Memory Index\n\n- [[existing]]: already here\n").unwrap();
|
||||
|
||||
let updated = ensure_index_entry(&index, "new_one", "newly added").unwrap();
|
||||
assert!(updated);
|
||||
let content = fs::read_to_string(&index).unwrap();
|
||||
assert!(content.contains("- [[existing]]: already here"));
|
||||
assert!(content.contains("- [[new_one]]: newly added"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_index_entry_skips_when_referenced() {
|
||||
let root = temp_root("index_skip");
|
||||
let index = root.join("MEMORY.md");
|
||||
let original = "# Memory Index\n\n- [[existing]]: already here\n";
|
||||
fs::write(&index, original).unwrap();
|
||||
|
||||
let updated = ensure_index_entry(&index, "existing", "different description").unwrap();
|
||||
assert!(!updated);
|
||||
assert_eq!(fs::read_to_string(&index).unwrap(), original);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_index_entry_creates_index_when_absent() {
|
||||
let root = temp_root("index_create");
|
||||
let index = root.join("memory").join("MEMORY.md");
|
||||
|
||||
let updated = ensure_index_entry(&index, "first", "first ever").unwrap();
|
||||
assert!(updated);
|
||||
let content = fs::read_to_string(&index).unwrap();
|
||||
assert!(content.starts_with("# Memory Index"));
|
||||
assert!(content.contains("- [[first]]: first ever"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_write_dir_returns_structured_dir_directly() {
|
||||
let root = temp_root("ws_structured");
|
||||
let workspace = root.join("ws");
|
||||
let structured = workspace.join(".coyote").join("memory");
|
||||
fs::create_dir_all(&structured).unwrap();
|
||||
fs::write(structured.join("MEMORY.md"), "idx").unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("g"),
|
||||
workspace: discover_workspace_memory(&workspace),
|
||||
};
|
||||
|
||||
let dir = workspace_write_dir(&store, &workspace).unwrap();
|
||||
assert_eq!(dir, structured);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_write_dir_promotes_lite_to_structured_subdir() {
|
||||
let root = temp_root("ws_lite_promote");
|
||||
let workspace = root.join("ws");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
fs::write(workspace.join("COYOTE.md"), "lite").unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("g"),
|
||||
workspace: discover_workspace_memory(&workspace),
|
||||
};
|
||||
|
||||
let dir = workspace_write_dir(&store, &workspace).unwrap();
|
||||
assert_eq!(dir, workspace.join(".coyote").join("memory"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_write_dir_errors_when_no_workspace_and_no_git() {
|
||||
let root = temp_root("ws_none");
|
||||
let bare = root.join("nowhere");
|
||||
fs::create_dir_all(&bare).unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("g"),
|
||||
workspace: discover_workspace_memory(&bare),
|
||||
};
|
||||
|
||||
let err = workspace_write_dir(&store, &bare).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("no workspace memory discoverable"));
|
||||
assert!(msg.contains("coyote --init-memory workspace"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_write_dir_auto_bootstraps_inside_git_repo() {
|
||||
let root = temp_root("ws_bootstrap");
|
||||
let repo = root.join("repo");
|
||||
fs::create_dir_all(repo.join(".git")).unwrap();
|
||||
let nested = repo.join("src").join("deep");
|
||||
fs::create_dir_all(&nested).unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("g"),
|
||||
workspace: discover_workspace_memory(&nested),
|
||||
};
|
||||
assert!(store.workspace.is_none());
|
||||
|
||||
let dir = workspace_write_dir(&store, &nested).unwrap();
|
||||
assert_eq!(dir, repo.join(".coyote").join("memory"));
|
||||
assert!(dir.join("MEMORY.md").exists());
|
||||
let gi = fs::read_to_string(repo.join(".gitignore")).unwrap();
|
||||
assert!(gi.contains(".coyote/memory/"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_file_returns_matching_file() {
|
||||
let root = temp_root("find_file");
|
||||
let workspace = root.join("ws");
|
||||
let structured = workspace.join(".coyote").join("memory");
|
||||
fs::create_dir_all(&structured).unwrap();
|
||||
fs::write(structured.join("MEMORY.md"), "idx").unwrap();
|
||||
fs::write(
|
||||
structured.join("target.md"),
|
||||
"---\nname: target\n---\nfound me\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
structured.join("other.md"),
|
||||
"---\nname: other\n---\nignored\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("g"),
|
||||
workspace: discover_workspace_memory(&workspace),
|
||||
};
|
||||
|
||||
let hit = find_file(&store, "target").unwrap();
|
||||
assert!(hit.is_some());
|
||||
assert_eq!(hit.unwrap().body.trim(), "found me");
|
||||
|
||||
let miss = find_file(&store, "nope").unwrap();
|
||||
assert!(miss.is_none());
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_memory_index_creates_dir_and_writes_content() {
|
||||
let root = temp_root("write_index_create");
|
||||
let target = root.join("nested").join(".coyote").join("memory");
|
||||
|
||||
let path =
|
||||
write_memory_index(&target, "# Workspace Memory Index\n\n- [[foo]]: hello\n").unwrap();
|
||||
|
||||
assert_eq!(path, target.join("MEMORY.md"));
|
||||
assert!(path.exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(&path).unwrap(),
|
||||
"# Workspace Memory Index\n\n- [[foo]]: hello\n"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_memory_index_replaces_existing_content() {
|
||||
let root = temp_root("write_index_replace");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let index = root.join("MEMORY.md");
|
||||
fs::write(&index, "# Old\n\n- [[stale]]: gone\n").unwrap();
|
||||
|
||||
let path = write_memory_index(&root, "# New\n").unwrap();
|
||||
|
||||
assert_eq!(path, index);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "# New\n");
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_flags_orphans_broken_links_and_oversized() {
|
||||
let root = temp_root("lint");
|
||||
let workspace = root.join("ws");
|
||||
let structured = workspace.join(".coyote").join("memory");
|
||||
fs::create_dir_all(&structured).unwrap();
|
||||
|
||||
fs::write(structured.join("MEMORY.md"), "- referenced\n").unwrap();
|
||||
fs::write(
|
||||
structured.join("referenced.md"),
|
||||
"---\nname: referenced\n---\nlinks to [[missing]] and [[also_missing]]\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
structured.join("orphan.md"),
|
||||
"---\nname: orphan\n---\nnot in the index\n",
|
||||
)
|
||||
.unwrap();
|
||||
let huge_body = "x".repeat(PER_FILE_SOFT_CAP + 100);
|
||||
fs::write(
|
||||
structured.join("huge.md"),
|
||||
format!("---\nname: huge\n---\n{huge_body}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let store = MemoryStore {
|
||||
global_dir: root.join("nonexistent_global"),
|
||||
workspace: discover_workspace_memory(&workspace),
|
||||
};
|
||||
|
||||
let report = lint_memory(&store).unwrap();
|
||||
assert_eq!(report["total_files"], 3);
|
||||
|
||||
let orphans = report["orphans"].as_array().unwrap();
|
||||
let orphan_names: Vec<&str> = orphans.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(orphan_names.contains(&"orphan"));
|
||||
assert!(orphan_names.contains(&"huge"));
|
||||
assert!(!orphan_names.contains(&"referenced"));
|
||||
|
||||
let broken = report["broken_wikilinks"].as_array().unwrap();
|
||||
let broken_targets: Vec<&str> = broken.iter().filter_map(|v| v["to"].as_str()).collect();
|
||||
assert!(broken_targets.contains(&"missing"));
|
||||
assert!(broken_targets.contains(&"also_missing"));
|
||||
|
||||
let oversized = report["oversized"].as_array().unwrap();
|
||||
let oversized_names: Vec<&str> = oversized
|
||||
.iter()
|
||||
.filter_map(|v| v["name"].as_str())
|
||||
.collect();
|
||||
assert_eq!(oversized_names, vec!["huge"]);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
+32
-1
@@ -1,3 +1,4 @@
|
||||
pub(crate) mod memory;
|
||||
pub(crate) mod skill;
|
||||
pub(crate) mod supervisor;
|
||||
pub(crate) mod todo;
|
||||
@@ -19,6 +20,7 @@ use crate::parsers::{bash, python, typescript};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use indexmap::IndexMap;
|
||||
use indoc::formatdoc;
|
||||
use memory::MEMORY_FUNCTION_PREFIX;
|
||||
use rust_embed::Embed;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -355,6 +357,21 @@ impl Functions {
|
||||
self.declarations.extend(todo::todo_function_declarations());
|
||||
}
|
||||
|
||||
pub fn remove_todo_functions(&mut self) {
|
||||
self.declarations
|
||||
.retain(|f| !f.name.starts_with(TODO_FUNCTION_PREFIX));
|
||||
}
|
||||
|
||||
pub fn append_memory_functions(&mut self) {
|
||||
self.declarations
|
||||
.extend(memory::memory_function_declarations());
|
||||
}
|
||||
|
||||
pub fn remove_memory_functions(&mut self) {
|
||||
self.declarations
|
||||
.retain(|f| !f.name.starts_with(MEMORY_FUNCTION_PREFIX));
|
||||
}
|
||||
|
||||
pub fn append_skill_functions(&mut self) {
|
||||
self.declarations
|
||||
.extend(skill::skill_function_declarations());
|
||||
@@ -1046,6 +1063,13 @@ impl ToolCall {
|
||||
json!({"tool_call_error": error_msg})
|
||||
})
|
||||
}
|
||||
_ if cmd_name.starts_with(MEMORY_FUNCTION_PREFIX) => {
|
||||
memory::handle_memory_tool(ctx, &cmd_name, &json_data).unwrap_or_else(|e| {
|
||||
let error_msg = format!("Memory tool failed: {e}");
|
||||
eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️")));
|
||||
json!({"tool_call_error": error_msg})
|
||||
})
|
||||
}
|
||||
_ if cmd_name.starts_with(SKILL_FUNCTION_PREFIX) => {
|
||||
skill::handle_skill_tool(ctx, &cmd_name, &json_data)
|
||||
.await
|
||||
@@ -1268,11 +1292,13 @@ pub fn run_llm_function(
|
||||
let mut buffer = [0; 1024];
|
||||
let mut reader = stdout;
|
||||
let mut out = io::stdout();
|
||||
let mut buf = Vec::new();
|
||||
while let Ok(n) = reader.read(&mut buffer) {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let chunk = &buffer[0..n];
|
||||
buf.extend_from_slice(chunk);
|
||||
let mut last_pos = 0;
|
||||
for (i, &byte) in chunk.iter().enumerate() {
|
||||
if byte == b'\n' {
|
||||
@@ -1286,6 +1312,7 @@ pub fn run_llm_function(
|
||||
}
|
||||
let _ = out.flush();
|
||||
}
|
||||
buf
|
||||
});
|
||||
|
||||
let stderr_thread = std::thread::spawn(move || {
|
||||
@@ -1318,18 +1345,22 @@ pub fn run_llm_function(
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|err| anyhow!("Unable to run {command_name}, {err}"))?;
|
||||
let _ = stdout_thread.join();
|
||||
let stdout_bytes = stdout_thread.join().unwrap_or_default();
|
||||
let stderr_bytes = stderr_thread.join().unwrap_or_default();
|
||||
|
||||
let exit_code = status.code().unwrap_or_default();
|
||||
if exit_code != 0 {
|
||||
let stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&stdout_bytes).trim().to_string();
|
||||
let tool_error_message = format!("Tool call '{command_name}' exited with code {exit_code}");
|
||||
eprintln!("{}", warning_text(&format!("⚠️ {tool_error_message} ⚠️")));
|
||||
let mut error_json = json!({"tool_call_error": tool_error_message});
|
||||
if !stderr.is_empty() {
|
||||
error_json["stderr"] = json!(stderr);
|
||||
}
|
||||
if !stdout.is_empty() {
|
||||
error_json["stdout"] = json!(stdout);
|
||||
}
|
||||
debug!("Tool call error: {error_json:?}");
|
||||
return Ok(Some(error_json.to_string()));
|
||||
}
|
||||
|
||||
+152
-18
@@ -3,7 +3,7 @@ use crate::client::{Model, ModelType, call_chat_completions};
|
||||
use crate::config::{Agent, AppState, Input, RequestContext, Role, RoleLike};
|
||||
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox};
|
||||
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor};
|
||||
use crate::utils::{AbortSignal, create_abort_signal};
|
||||
use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal};
|
||||
|
||||
use crate::graph;
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
@@ -16,10 +16,69 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time;
|
||||
use tokio::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const SUPERVISOR_FUNCTION_PREFIX: &str = "agent__";
|
||||
|
||||
pub const PENDING_AGENTS_GUARDRAIL_MAX: u32 = 3;
|
||||
|
||||
pub enum GuardrailAction {
|
||||
NoAction,
|
||||
Inject(String),
|
||||
ForceTerminate(Vec<String>),
|
||||
}
|
||||
|
||||
pub fn pending_agent_ids(ctx: &RequestContext) -> Vec<String> {
|
||||
let Some(sup) = ctx.supervisor.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let sup = sup.read();
|
||||
sup.list_agents()
|
||||
.into_iter()
|
||||
.filter_map(|(id, _)| match sup.is_finished(id) {
|
||||
Some(false) => Some(id.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn build_pending_agents_guardrail_prompt(ids: &[String]) -> String {
|
||||
let count = ids.len();
|
||||
let id_list = ids
|
||||
.iter()
|
||||
.map(|id| format!("- {id}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!(
|
||||
"[SYSTEM GUARDRAIL] You attempted to end your turn while {count} spawned background agent(s) \
|
||||
are still running:\n{id_list}\n\nThese agents will be abandoned if your turn ends now. You MUST \
|
||||
reclaim each one before ending your turn. For each agent: call `agent__collect` (blocks until \
|
||||
done, returns output) or `agent__cancel` (discards). Do NOT emit a text-only response \
|
||||
expecting them to 'report back' — they will not."
|
||||
)
|
||||
}
|
||||
|
||||
pub fn check_pending_agents_guardrail(ctx: &mut RequestContext) -> GuardrailAction {
|
||||
let pending = pending_agent_ids(ctx);
|
||||
if pending.is_empty() {
|
||||
ctx.pending_agents_guardrail_count = 0;
|
||||
return GuardrailAction::NoAction;
|
||||
}
|
||||
|
||||
if ctx.pending_agents_guardrail_count >= PENDING_AGENTS_GUARDRAIL_MAX {
|
||||
if let Some(sup) = ctx.supervisor.as_ref().cloned() {
|
||||
sup.read().cancel_recursive();
|
||||
}
|
||||
ctx.pending_agents_guardrail_count = 0;
|
||||
|
||||
return GuardrailAction::ForceTerminate(pending);
|
||||
}
|
||||
|
||||
ctx.pending_agents_guardrail_count += 1;
|
||||
GuardrailAction::Inject(build_pending_agents_guardrail_prompt(&pending))
|
||||
}
|
||||
|
||||
pub fn escalation_function_declarations() -> Vec<FunctionDeclaration> {
|
||||
vec![FunctionDeclaration {
|
||||
name: format!("{SUPERVISOR_FUNCTION_PREFIX}reply_escalation"),
|
||||
@@ -55,7 +114,11 @@ pub fn supervisor_function_declarations() -> Vec<FunctionDeclaration> {
|
||||
vec![
|
||||
FunctionDeclaration {
|
||||
name: format!("{SUPERVISOR_FUNCTION_PREFIX}spawn"),
|
||||
description: "Spawn a subagent to run in the background. Returns a task_id for tracking. The agent runs in parallel. You can continue working while it executes.".to_string(),
|
||||
description: "Spawn a subagent to run in the background. Returns an `id` immediately so you can continue \
|
||||
working in parallel. CRITICAL: every spawned agent MUST be reclaimed before you end your \
|
||||
turn — call `agent__collect` to retrieve its output, or `agent__cancel` if you no longer \
|
||||
need it. Ending your turn with pending agents will abandon their work and the system will \
|
||||
reject the turn-end.".to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(IndexMap::from([
|
||||
@@ -109,7 +172,11 @@ pub fn supervisor_function_declarations() -> Vec<FunctionDeclaration> {
|
||||
},
|
||||
FunctionDeclaration {
|
||||
name: format!("{SUPERVISOR_FUNCTION_PREFIX}collect"),
|
||||
description: "Wait for a spawned agent to finish and return its result. Blocks until the agent completes.".to_string(),
|
||||
description: "Block until the named spawned agent finishes and return its result. This is your primary \
|
||||
wait primitive — it pauses your execution until the agent completes (or you are interrupted). \
|
||||
Call this for every agent you spawned before ending your turn. Do NOT end your turn assuming \
|
||||
agents will 'report back later' — they will not; they will be abandoned. If you no longer \
|
||||
need an agent's result, call `agent__cancel` instead.".to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(IndexMap::from([(
|
||||
@@ -137,7 +204,10 @@ pub fn supervisor_function_declarations() -> Vec<FunctionDeclaration> {
|
||||
},
|
||||
FunctionDeclaration {
|
||||
name: format!("{SUPERVISOR_FUNCTION_PREFIX}cancel"),
|
||||
description: "Cancel a running subagent by its ID.".to_string(),
|
||||
description: "Cancel a running subagent by its ID. Use this when an agent's output is no longer needed \
|
||||
(e.g. you changed direction, or you're about to end your turn and don't want to wait). \
|
||||
Cancellation cascades: all of the cancelled agent's own descendants are also cancelled. This \
|
||||
call waits briefly for the agent to actually finish cleanup before returning.".to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
properties: Some(IndexMap::from([(
|
||||
@@ -315,7 +385,7 @@ pub async fn handle_supervisor_tool(
|
||||
"check" => handle_check(ctx, args).await,
|
||||
"collect" => handle_collect(ctx, args).await,
|
||||
"list" => handle_list(ctx),
|
||||
"cancel" => handle_cancel(ctx, args),
|
||||
"cancel" => handle_cancel(ctx, args).await,
|
||||
"send_message" => handle_send_message(ctx, args),
|
||||
"check_inbox" => handle_check_inbox(ctx),
|
||||
"task_create" => handle_task_create(ctx, args),
|
||||
@@ -370,14 +440,28 @@ pub fn run_child_agent(
|
||||
}
|
||||
|
||||
if tool_results.is_empty() {
|
||||
break;
|
||||
match check_pending_agents_guardrail(&mut child_ctx) {
|
||||
GuardrailAction::NoAction => break,
|
||||
GuardrailAction::ForceTerminate(ids) => {
|
||||
log::warn!(
|
||||
"Pending-agent guardrail force-cancelled {} agent(s) after max reminders: {:?}",
|
||||
ids.len(),
|
||||
ids
|
||||
);
|
||||
break;
|
||||
}
|
||||
GuardrailAction::Inject(prompt) => {
|
||||
input = Input::from_str(&child_ctx, &prompt, None)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input = input.merge_tool_results(output, tool_results);
|
||||
}
|
||||
|
||||
if let Some(supervisor) = child_ctx.supervisor.clone() {
|
||||
supervisor.read().cancel_all();
|
||||
supervisor.read().cancel_recursive();
|
||||
}
|
||||
|
||||
Ok(accumulated_output)
|
||||
@@ -642,6 +726,7 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
let spawn_agent_id = agent_id.clone();
|
||||
let spawn_agent_name = agent_name.clone();
|
||||
let spawn_abort = child_abort.clone();
|
||||
let child_supervisor = child_ctx.supervisor.clone();
|
||||
|
||||
let join_handle = tokio::spawn(async move {
|
||||
let result = run_child_agent(child_ctx, input, spawn_abort).await;
|
||||
@@ -669,6 +754,7 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
inbox: child_inbox,
|
||||
abort_signal: child_abort,
|
||||
join_handle,
|
||||
child_supervisor,
|
||||
};
|
||||
|
||||
let supervisor = ctx
|
||||
@@ -683,7 +769,11 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
"status": "ok",
|
||||
"id": agent_id,
|
||||
"agent": agent_name,
|
||||
"message": format!("Agent '{agent_name}' spawned as '{agent_id}'. Use agent__check or agent__collect to get results."),
|
||||
"message": format!("Agent '{agent_name}' spawned as '{agent_id}' and is running in the background. CRITICAL: \
|
||||
you MUST reclaim this agent before ending your turn — call `agent__collect` (blocks until \
|
||||
done, returns output) or `agent__cancel` (if you no longer need it). Ending your turn with \
|
||||
unreclaimed agents will be rejected and forces you to handle them. Do NOT assume the agent \
|
||||
will 'report back' on its own."),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -743,7 +833,7 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("No supervisor active"))?;
|
||||
|
||||
{
|
||||
let target_abort = {
|
||||
let sup = supervisor.read();
|
||||
if sup.is_finished(id).is_none() {
|
||||
return Ok(json!({
|
||||
@@ -751,7 +841,8 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
||||
"message": format!("Agent '{id}' not found. Use agent__check to verify it exists and is finished.")
|
||||
}));
|
||||
}
|
||||
}
|
||||
sup.abort_signal_for(id)
|
||||
};
|
||||
|
||||
loop {
|
||||
let is_finished = {
|
||||
@@ -775,7 +866,27 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
||||
}));
|
||||
}
|
||||
|
||||
time::sleep(Duration::from_millis(200)).await;
|
||||
match target_abort.as_ref() {
|
||||
Some(abort) if abort.aborted() => {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
if supervisor.read().is_finished(id).unwrap_or(false) {
|
||||
break;
|
||||
}
|
||||
time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
Some(abort) => {
|
||||
tokio::select! {
|
||||
_ = time::sleep(Duration::from_millis(200)) => {}
|
||||
_ = wait_abort_signal(abort) => {}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let handle = {
|
||||
@@ -792,6 +903,7 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
||||
.map_err(|e| anyhow!("Agent failed: {e}"))?;
|
||||
|
||||
let output = summarize_output(ctx, &result.agent_name, &result.output).await?;
|
||||
ctx.pending_agents_guardrail_count = 0;
|
||||
|
||||
Ok(json!({
|
||||
"status": "completed",
|
||||
@@ -836,7 +948,7 @@ fn handle_list(ctx: &mut RequestContext) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
fn handle_cancel(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
async fn handle_cancel(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
let id = args
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
@@ -847,14 +959,34 @@ fn handle_cancel(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("No supervisor active"))?;
|
||||
let mut sup = supervisor.write();
|
||||
|
||||
match sup.take(id) {
|
||||
let handle = {
|
||||
let mut sup = supervisor.write();
|
||||
sup.take(id)
|
||||
};
|
||||
|
||||
match handle {
|
||||
Some(handle) => {
|
||||
let agent_name = handle.agent_name.clone();
|
||||
if let Some(child_sup) = handle.child_supervisor.as_ref() {
|
||||
child_sup.read().cancel_recursive();
|
||||
}
|
||||
handle.abort_signal.set_ctrlc();
|
||||
|
||||
let cleanup = tokio::time::timeout(Duration::from_secs(5), handle.join_handle).await;
|
||||
|
||||
ctx.pending_agents_guardrail_count = 0;
|
||||
|
||||
let message = match cleanup {
|
||||
Ok(_) => format!("Cancelled agent '{agent_name}' and waited for cleanup."),
|
||||
Err(_) => format!(
|
||||
"Cancelled agent '{agent_name}'; cleanup did not complete within 5s. Its descendants have been signalled and will tear down asynchronously."
|
||||
),
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"status": "ok",
|
||||
"message": format!("Cancelled agent '{}'", handle.agent_name),
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
None => Ok(json!({
|
||||
@@ -1283,6 +1415,7 @@ mod tests {
|
||||
inbox: Arc::new(Inbox::new()),
|
||||
abort_signal: create_abort_signal(),
|
||||
join_handle,
|
||||
child_supervisor: None,
|
||||
};
|
||||
ctx.supervisor
|
||||
.as_ref()
|
||||
@@ -1362,6 +1495,7 @@ mod tests {
|
||||
inbox,
|
||||
abort_signal: abort,
|
||||
join_handle,
|
||||
child_supervisor: None,
|
||||
};
|
||||
ctx.supervisor
|
||||
.as_ref()
|
||||
@@ -1381,7 +1515,7 @@ mod tests {
|
||||
fn handle_cancel_registered_agent() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
register_fake_agent(&mut ctx, "a1", "explore");
|
||||
let result = handle_cancel(&mut ctx, &json!({"id": "a1"})).unwrap();
|
||||
let result = run_async(handle_cancel(&mut ctx, &json!({"id": "a1"}))).unwrap();
|
||||
assert_eq!(result["status"], "ok");
|
||||
assert_eq!(ctx.supervisor.as_ref().unwrap().read().active_count(), 0);
|
||||
}
|
||||
@@ -1389,14 +1523,14 @@ mod tests {
|
||||
#[test]
|
||||
fn handle_cancel_unknown_agent() {
|
||||
let mut ctx = ctx_with_supervisor(4, 3);
|
||||
let result = handle_cancel(&mut ctx, &json!({"id": "missing"})).unwrap();
|
||||
let result = run_async(handle_cancel(&mut ctx, &json!({"id": "missing"}))).unwrap();
|
||||
assert_eq!(result["status"], "error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_cancel_no_supervisor_errors() {
|
||||
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
|
||||
let result = handle_cancel(&mut ctx, &json!({"id": "x"}));
|
||||
let result = run_async(handle_cancel(&mut ctx, &json!({"id": "x"})));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
|
||||
+24
-1
@@ -7,8 +7,10 @@ use crate::config::{
|
||||
Input, RequestContext, Role, RoleLike, SkillPolicy, should_inject_skill_instructions,
|
||||
};
|
||||
use crate::function::skill::skill_function_declarations;
|
||||
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
||||
use crate::utils::create_abort_signal;
|
||||
use anyhow::{Context, Error, Result, anyhow, bail};
|
||||
use log::warn;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
@@ -266,7 +268,28 @@ async fn run_chat_loop(node: &LlmNode, prompt: &str, ctx: &mut RequestContext) -
|
||||
}
|
||||
|
||||
if tool_results.is_empty() {
|
||||
return Ok(accumulated);
|
||||
match check_pending_agents_guardrail(ctx) {
|
||||
GuardrailAction::NoAction => return Ok(accumulated),
|
||||
GuardrailAction::ForceTerminate(ids) => {
|
||||
warn!(
|
||||
"Pending-agent guardrail force-cancelled {} agent(s) after max reminders: {:?}",
|
||||
ids.len(),
|
||||
ids
|
||||
);
|
||||
return Ok(accumulated);
|
||||
}
|
||||
GuardrailAction::Inject(prompt) => {
|
||||
if turn + 1 == node.max_iterations {
|
||||
bail!(
|
||||
"llm node hit max_iterations ({}) before LLM concluded",
|
||||
node.max_iterations
|
||||
);
|
||||
}
|
||||
let role = ctx.role.clone();
|
||||
input = Input::from_str(ctx, &prompt, role)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if turn + 1 == node.max_iterations {
|
||||
|
||||
+55
-5
@@ -10,6 +10,7 @@ mod repl;
|
||||
mod utils;
|
||||
mod mcp;
|
||||
mod parsers;
|
||||
mod sandbox;
|
||||
mod supervisor;
|
||||
mod vault;
|
||||
|
||||
@@ -22,10 +23,11 @@ use crate::client::{
|
||||
};
|
||||
use crate::config::paths;
|
||||
use crate::config::{
|
||||
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, RequestContext,
|
||||
SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, install_builtins,
|
||||
list_agents, load_env_file, macro_execute, sync_models,
|
||||
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
|
||||
RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
|
||||
install_builtins, list_agents, load_env_file, macro_execute, sync_models,
|
||||
};
|
||||
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
||||
use crate::render::{prompt_theme, render_error};
|
||||
use crate::repl::Repl;
|
||||
use crate::utils::*;
|
||||
@@ -35,14 +37,14 @@ use clap::{CommandFactory, Parser};
|
||||
use clap_complete::CompleteEnv;
|
||||
use client::ClientConfig;
|
||||
use inquire::{Select, Text, set_global_render_config};
|
||||
use log::LevelFilter;
|
||||
use log::{LevelFilter, warn};
|
||||
use log4rs::append::console::ConsoleAppender;
|
||||
use log4rs::append::file::FileAppender;
|
||||
use log4rs::config::{Appender, Logger, Root};
|
||||
use log4rs::encode::pattern::PatternEncoder;
|
||||
use oauth::OAuthProvider;
|
||||
use std::path::PathBuf;
|
||||
use std::{env, process, sync::Arc};
|
||||
use std::{env, fs, process, sync::Arc};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
@@ -55,6 +57,7 @@ async fn main() -> Result<()> {
|
||||
shell.generate_completions(&mut cmd);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if cli.tail_logs {
|
||||
tail_logs(cli.disable_log_colors).await;
|
||||
return Ok(());
|
||||
@@ -91,6 +94,10 @@ async fn main() -> Result<()> {
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(name) = &cli.sandbox {
|
||||
return sandbox::launch(name.clone(), cli.fresh, cli.no_mixins);
|
||||
}
|
||||
|
||||
install_builtins()?;
|
||||
|
||||
if let Some(category) = cli.install {
|
||||
@@ -292,12 +299,40 @@ async fn run(
|
||||
if cli.no_stream {
|
||||
update_app_config(&mut ctx, |app| app.stream = false);
|
||||
}
|
||||
if cli.no_memory {
|
||||
update_app_config(&mut ctx, |app| app.memory = Some(false));
|
||||
}
|
||||
if cli.empty_session {
|
||||
ctx.empty_session()?;
|
||||
}
|
||||
if cli.save_session {
|
||||
ctx.set_save_session_this_time()?;
|
||||
}
|
||||
if let Some(scope) = cli.init_memory {
|
||||
let (path, content) = match scope {
|
||||
MemoryScope::Global => (
|
||||
paths::global_memory_index_path(),
|
||||
"# Global Memory\n\n<!-- Universal facts about you go here. The LLM uses this as always-on context. -->\n<!-- Drill files (when created) are listed below. -->\n",
|
||||
),
|
||||
MemoryScope::Workspace => (
|
||||
env::current_dir()?.join("COYOTE.md"),
|
||||
"# Workspace Memory\n\n<!-- Facts about this project go here. The LLM uses this as always-on context. -->\n",
|
||||
),
|
||||
};
|
||||
|
||||
if path.exists() {
|
||||
eprintln!("Memory marker already exists at '{}'.", path.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
fs::write(&path, content)?;
|
||||
println!("✓ Created memory marker at '{}'.", path.display());
|
||||
return Ok(());
|
||||
}
|
||||
if cli.info {
|
||||
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
||||
let info = ctx.info(app.as_ref())?;
|
||||
@@ -391,6 +426,21 @@ async fn start_directive(
|
||||
abort_signal,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
match check_pending_agents_guardrail(ctx) {
|
||||
GuardrailAction::Inject(prompt) => {
|
||||
let guardrail_input = Input::from_str(ctx, &prompt, None)?;
|
||||
return start_directive(ctx, guardrail_input, code_mode, abort_signal).await;
|
||||
}
|
||||
GuardrailAction::ForceTerminate(ids) => {
|
||||
warn!(
|
||||
"Pending-agent guardrail force-cancelled {} agent(s) after max reminders: {:?}",
|
||||
ids.len(),
|
||||
ids
|
||||
);
|
||||
}
|
||||
GuardrailAction::NoAction => {}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.exit_session()?;
|
||||
|
||||
+3
-3
@@ -16,8 +16,8 @@ use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::HashMap, env, fmt, fmt::Debug, fs, hash::Hash, path::Path, sync::Arc,
|
||||
time::Duration,
|
||||
cmp::Ordering, collections::HashMap, env, fmt, fmt::Debug, fs, hash::Hash, path::Path,
|
||||
sync::Arc, time::Duration,
|
||||
};
|
||||
use tokio::time::sleep;
|
||||
|
||||
@@ -1196,7 +1196,7 @@ fn reciprocal_rank_fusion(
|
||||
}
|
||||
}
|
||||
let mut sorted_items: Vec<(DocumentId, f32)> = map.into_iter().collect();
|
||||
sorted_items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||
sorted_items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
|
||||
|
||||
sorted_items
|
||||
.into_iter()
|
||||
|
||||
+151
-11
@@ -12,11 +12,14 @@ use crate::config::{
|
||||
macro_execute,
|
||||
};
|
||||
use crate::config::{AssetCategory, paths};
|
||||
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
||||
use crate::render::render_error;
|
||||
use crate::utils::{
|
||||
AbortSignal, abortable_run_with_spinner, create_abort_signal, dimmed_text, set_text, temp_file,
|
||||
AbortSignal, SHELL, abortable_run_with_spinner, create_abort_signal, dimmed_text, run_command,
|
||||
set_text, temp_file,
|
||||
};
|
||||
|
||||
use crate::sandbox::SANDBOX_ENV_FLAG;
|
||||
use crate::{config, graph, resolve_oauth_client};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
@@ -46,10 +49,15 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
||||
4. Continue with the next pending item now. Call tools immediately."
|
||||
};
|
||||
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 44]> = LazyLock::new(|| {
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 49]> = LazyLock::new(|| {
|
||||
[
|
||||
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
||||
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
||||
ReplCommand::new(
|
||||
".info tools",
|
||||
"Show the list of enabled tools to be passed to the LLM",
|
||||
AssertState::True(StateFlags::FUNCTION_CALLING),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".authenticate",
|
||||
"Authenticate the current model client via OAuth (if configured)",
|
||||
@@ -160,6 +168,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 44]> = LazyLock::new(|| {
|
||||
"Clear the todo list and stop auto-continuation",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".info todo",
|
||||
"Show the current todo list driving auto-continuation",
|
||||
AssertState::True(StateFlags::AUTO_CONTINUE),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".rag",
|
||||
"Initialize or access RAG",
|
||||
@@ -193,13 +206,28 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 44]> = LazyLock::new(|| {
|
||||
ReplCommand::new(".macro", "Execute a macro", AssertState::pass()),
|
||||
ReplCommand::new(
|
||||
".skill",
|
||||
"List, load, unload, or create skills",
|
||||
AssertState::pass(),
|
||||
"Create a new skill",
|
||||
AssertState::True(StateFlags::SKILLS_ENABLED),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".skill load",
|
||||
"Load a skill into the current context",
|
||||
AssertState::True(StateFlags::SKILLS_ENABLED),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".skill loaded",
|
||||
"List currently-loaded skills",
|
||||
AssertState::True(StateFlags::SKILLS_ENABLED),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".skill unload",
|
||||
"Unload a skill from the current context",
|
||||
AssertState::True(StateFlags::SKILLS_ENABLED),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".edit skill",
|
||||
"Modify an existing skill by name",
|
||||
AssertState::pass(),
|
||||
AssertState::True(StateFlags::SKILLS_ENABLED),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".file",
|
||||
@@ -277,7 +305,12 @@ Type ".help" for additional help.
|
||||
"#,
|
||||
env!("CARGO_CRATE_NAME"),
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
)
|
||||
);
|
||||
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
||||
eprintln!(
|
||||
"Sandbox mode is enabled. All changes made to the Coyote config will not persist to the host machine."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
@@ -306,6 +339,9 @@ Type ".help" for additional help.
|
||||
}
|
||||
Ok(Signal::CtrlC) => {
|
||||
self.abort_signal.set_ctrlc();
|
||||
if let Some(supervisor) = self.ctx.read().supervisor.clone() {
|
||||
supervisor.read().cancel_recursive();
|
||||
}
|
||||
println!("(To exit, press Ctrl+D or enter \".exit\")\n");
|
||||
}
|
||||
Ok(Signal::CtrlD) => {
|
||||
@@ -315,6 +351,11 @@ Type ".help" for additional help.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(supervisor) = self.ctx.read().supervisor.clone() {
|
||||
supervisor.read().cancel_recursive();
|
||||
}
|
||||
|
||||
self.ctx.write().exit_session()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -435,6 +476,7 @@ pub async fn run_repl_command(
|
||||
abort_signal: AbortSignal,
|
||||
mut line: &str,
|
||||
) -> Result<bool> {
|
||||
ctx.pending_agents_guardrail_count = 0;
|
||||
if let Ok(Some(captures)) = MULTILINE_RE.captures(line)
|
||||
&& let Some(text_match) = captures.get(1)
|
||||
{
|
||||
@@ -463,6 +505,14 @@ pub async fn run_repl_command(
|
||||
let info = ctx.agent_info()?;
|
||||
print!("{info}");
|
||||
}
|
||||
Some("tools") => {
|
||||
let info = ctx.tools_info()?;
|
||||
print!("{info}");
|
||||
}
|
||||
Some("todo") => {
|
||||
let info = ctx.todo_info()?;
|
||||
print!("{info}");
|
||||
}
|
||||
Some(_) => unknown_command()?,
|
||||
None => {
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
@@ -945,9 +995,13 @@ pub async fn run_repl_command(
|
||||
_ => unknown_command()?,
|
||||
},
|
||||
None => {
|
||||
reset_continuation(ctx);
|
||||
let input = Input::from_str(ctx, line, None)?;
|
||||
ask(ctx, abort_signal.clone(), input, true).await?;
|
||||
if let Some(cmd) = try_extract_shell_command(line) {
|
||||
handle_shell_passthrough(cmd)?;
|
||||
} else {
|
||||
reset_continuation(ctx);
|
||||
let input = Input::from_str(ctx, line, None)?;
|
||||
ask(ctx, abort_signal.clone(), input, true).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1011,6 +1065,20 @@ async fn ask(
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
match check_pending_agents_guardrail(ctx) {
|
||||
GuardrailAction::Inject(prompt) => {
|
||||
let guardrail_input = Input::from_str(ctx, &prompt, None)?;
|
||||
return ask(ctx, abort_signal, guardrail_input, false).await;
|
||||
}
|
||||
GuardrailAction::ForceTerminate(ids) => {
|
||||
warn!(
|
||||
"Pending-agent guardrail force-cancelled {} agent(s) after max reminders: {:?}",
|
||||
ids.len(),
|
||||
ids
|
||||
);
|
||||
}
|
||||
GuardrailAction::NoAction => {}
|
||||
}
|
||||
let do_continue = should_continue(ctx);
|
||||
|
||||
if do_continue {
|
||||
@@ -1149,10 +1217,12 @@ fn dump_repl_help() {
|
||||
.join("\n");
|
||||
println!(
|
||||
r###"{head}
|
||||
{:<24} Run an arbitrary shell command (stdout/stderr stream to your terminal; Ctrl+C interrupts)
|
||||
|
||||
Type ::: to start multi-line editing, type ::: to finish it.
|
||||
Press Ctrl+O to open an editor for editing the input buffer.
|
||||
Press Ctrl+C to cancel the response, Ctrl+D to exit the REPL."###,
|
||||
"!<command>",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1168,6 +1238,25 @@ fn parse_command(line: &str) -> Option<(&str, Option<&str>)> {
|
||||
}
|
||||
}
|
||||
|
||||
fn try_extract_shell_command(line: &str) -> Option<&str> {
|
||||
let rest = line.strip_prefix('!')?;
|
||||
Some(rest.trim_start())
|
||||
}
|
||||
|
||||
fn handle_shell_passthrough(cmd: &str) -> Result<()> {
|
||||
if cmd.is_empty() {
|
||||
eprintln!("Usage: !<command>");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let status = run_command(&SHELL.cmd, &[&SHELL.arg, cmd], None)?;
|
||||
if status != 0 {
|
||||
eprintln!("[exit {status}]");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn split_first_arg(args: Option<&str>) -> Option<(&str, Option<&str>)> {
|
||||
args.map(|v| match v.split_once(' ') {
|
||||
Some((subcmd, args)) => (subcmd, Some(args.trim())),
|
||||
@@ -1326,8 +1415,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_commands_has_44_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 44);
|
||||
fn repl_commands_has_49_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 49);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1502,6 +1591,57 @@ mod tests {
|
||||
assert_eq!(parse_command("."), Some((".", None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_extract_shell_command_strips_bang() {
|
||||
assert_eq!(try_extract_shell_command("!ls"), Some("ls"));
|
||||
assert_eq!(try_extract_shell_command("!ls -la"), Some("ls -la"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_extract_shell_command_trims_inner_whitespace() {
|
||||
assert_eq!(try_extract_shell_command("! echo hi"), Some("echo hi"));
|
||||
assert_eq!(try_extract_shell_command("! ls"), Some("ls"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_extract_shell_command_only_bang_yields_empty() {
|
||||
assert_eq!(try_extract_shell_command("!"), Some(""));
|
||||
assert_eq!(try_extract_shell_command("! "), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_extract_shell_command_rejects_leading_whitespace() {
|
||||
assert!(try_extract_shell_command(" !ls").is_none());
|
||||
assert!(try_extract_shell_command("\t!ls").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_extract_shell_command_rejects_inline_bang() {
|
||||
assert!(try_extract_shell_command("echo !foo").is_none());
|
||||
assert!(try_extract_shell_command("hello world").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_extract_shell_command_strips_one_leading_bang() {
|
||||
assert_eq!(try_extract_shell_command("!!ls"), Some("!ls"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_extract_shell_command_preserves_pipes_and_redirects() {
|
||||
assert_eq!(
|
||||
try_extract_shell_command("!ls -la | grep yaml"),
|
||||
Some("ls -la | grep yaml")
|
||||
);
|
||||
assert_eq!(
|
||||
try_extract_shell_command("!cat foo.txt > /tmp/out"),
|
||||
Some("cat foo.txt > /tmp/out")
|
||||
);
|
||||
assert_eq!(
|
||||
try_extract_shell_command(r#"!echo "$HOME""#),
|
||||
Some(r#"echo "$HOME""#)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_first_arg_none_input() {
|
||||
assert!(split_first_arg(None).is_none());
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::fs::{read_dir, read_to_string};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_yaml::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::config::paths;
|
||||
|
||||
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
||||
const KIT_SPEC_FILE_NAME: &str = "spec.yaml";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredMixin {
|
||||
pub path: PathBuf,
|
||||
pub label: String,
|
||||
pub install_count: usize,
|
||||
pub domain_count: usize,
|
||||
}
|
||||
|
||||
impl DiscoveredMixin {
|
||||
pub fn kit_path(&self) -> Result<PathBuf> {
|
||||
if self.path.is_dir() {
|
||||
return Ok(self.path.clone());
|
||||
}
|
||||
|
||||
wrap_mixin_as_kit(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wrap_mixin_as_kit(mixin_path: &Path) -> Result<PathBuf> {
|
||||
let bytes = fs::read(mixin_path)
|
||||
.with_context(|| format!("Failed to read sbx mixin {}", mixin_path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&bytes);
|
||||
let hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
let kit_dir = paths::sbx_mixin_kits_dir().join(&hash);
|
||||
let spec_path = kit_dir.join(KIT_SPEC_FILE_NAME);
|
||||
|
||||
if let Ok(existing) = fs::read(&spec_path)
|
||||
&& existing == bytes
|
||||
{
|
||||
return Ok(kit_dir);
|
||||
}
|
||||
|
||||
fs::create_dir_all(&kit_dir)
|
||||
.with_context(|| format!("Failed to create mixin kit dir {}", kit_dir.display()))?;
|
||||
fs::write(&spec_path, &bytes)
|
||||
.with_context(|| format!("Failed to write {}", spec_path.display()))?;
|
||||
|
||||
debug!(
|
||||
"Wrapped mixin {} as kit at {}",
|
||||
mixin_path.display(),
|
||||
kit_dir.display()
|
||||
);
|
||||
|
||||
Ok(kit_dir)
|
||||
}
|
||||
|
||||
pub fn discover() -> Result<Vec<DiscoveredMixin>> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
push_if_exists(&mut out, paths::sbx_mixin_file())?;
|
||||
push_if_exists(&mut out, paths::global_tools_sbx_mixin_file())?;
|
||||
|
||||
for path in collect_subdir_mixins(&paths::functions_dir()) {
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
for path in collect_subdir_mixins(&paths::agents_data_dir()) {
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
|
||||
if let Ok(cwd) = env::current_dir()
|
||||
&& let Some(path) = paths::find_workspace_sbx_mixin(&cwd)
|
||||
{
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn summarize(path: &Path) -> Result<(usize, usize)> {
|
||||
let content = read_to_string(path)
|
||||
.with_context(|| format!("Failed to read sbx mixin {}", path.display()))?;
|
||||
let value: Value = serde_yaml::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse sbx mixin {}", path.display()))?;
|
||||
|
||||
let installs = value
|
||||
.get("commands")
|
||||
.and_then(|c| c.get("install"))
|
||||
.and_then(|i| i.as_sequence())
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let domains = value
|
||||
.get("network")
|
||||
.and_then(|n| n.get("allowedDomains"))
|
||||
.and_then(|d| d.as_sequence())
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok((installs, domains))
|
||||
}
|
||||
|
||||
pub fn log_discovery(mixins: &[DiscoveredMixin], disabled: bool) {
|
||||
if disabled {
|
||||
info!("Mixin discovery disabled via --no-mixins.");
|
||||
return;
|
||||
}
|
||||
|
||||
if mixins.is_empty() {
|
||||
info!("No sbx mixins discovered.");
|
||||
return;
|
||||
}
|
||||
|
||||
let header = format!("Applying {} sbx mixin(s):", mixins.len());
|
||||
info!("{header}");
|
||||
println!("{header}");
|
||||
|
||||
for m in mixins {
|
||||
let line = format!(
|
||||
" {} (adds: {} install{}, {} domain{})",
|
||||
m.label,
|
||||
m.install_count,
|
||||
if m.install_count == 1 { "" } else { "s" },
|
||||
m.domain_count,
|
||||
if m.domain_count == 1 { "" } else { "s" },
|
||||
);
|
||||
info!("{line}");
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
fn push_if_exists(out: &mut Vec<DiscoveredMixin>, path: PathBuf) -> Result<()> {
|
||||
if path.exists() {
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_mixin(path: PathBuf) -> Result<DiscoveredMixin> {
|
||||
let label = path.display().to_string();
|
||||
let (install_count, domain_count) = summarize(&path)?;
|
||||
|
||||
Ok(DiscoveredMixin {
|
||||
path,
|
||||
label,
|
||||
install_count,
|
||||
domain_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_subdir_mixins(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut result = Vec::new();
|
||||
let Ok(rd) = read_dir(dir) else { return result };
|
||||
|
||||
let mut entries: Vec<_> = rd
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let candidate = entry.path().join(SBX_MIXIN_FILE_NAME);
|
||||
if candidate.exists() {
|
||||
result.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::time;
|
||||
|
||||
fn unique_root(prefix: &str) -> PathBuf {
|
||||
let nanos = time::SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let root = env::temp_dir().join(format!("coyote-{prefix}-{nanos}"));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_counts_installs_and_domains() {
|
||||
let root = unique_root("sbx-mixin-counts");
|
||||
let path = root.join("sbx-mixin.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
commands:
|
||||
install:
|
||||
- command: "echo hi"
|
||||
- command: "echo bye"
|
||||
network:
|
||||
allowedDomains:
|
||||
- "a.example.com:443"
|
||||
- "b.example.com:443"
|
||||
- "c.example.com:443"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(summarize(&path).unwrap(), (2, 3));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_treats_missing_blocks_as_zero() {
|
||||
let root = unique_root("sbx-mixin-empty");
|
||||
let path = root.join("sbx-mixin.yaml");
|
||||
fs::write(&path, "schemaVersion: \"1\"\nkind: mixin\n").unwrap();
|
||||
|
||||
assert_eq!(summarize(&path).unwrap(), (0, 0));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_returns_err_on_malformed_yaml() {
|
||||
let root = unique_root("sbx-mixin-bad");
|
||||
let path = root.join("sbx-mixin.yaml");
|
||||
fs::write(&path, "this: is: not: yaml: ::").unwrap();
|
||||
|
||||
let err = summarize(&path).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains(&path.display().to_string()),
|
||||
"expected error to mention path; got: {msg}"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_subdir_mixins_sorts_and_skips_missing() {
|
||||
let root = unique_root("sbx-mixin-subdirs");
|
||||
for name in ["zebra", "apple", "no-mixin", "mango"] {
|
||||
let dir = root.join(name);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
if name != "no-mixin" {
|
||||
fs::write(dir.join("sbx-mixin.yaml"), "kind: mixin\n").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let found = collect_subdir_mixins(&root);
|
||||
let names: Vec<String> = found
|
||||
.iter()
|
||||
.map(|p| {
|
||||
p.parent()
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(names, vec!["apple", "mango", "zebra"]);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_subdir_mixins_returns_empty_for_missing_dir() {
|
||||
let absent = env::temp_dir().join("coyote-definitely-not-here-xyz");
|
||||
let found = collect_subdir_mixins(&absent);
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
|
||||
mod wrap_as_kit {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
use std::ffi::OsString;
|
||||
|
||||
struct TestCacheDirGuard {
|
||||
key: String,
|
||||
previous: Option<OsString>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestCacheDirGuard {
|
||||
fn new() -> Self {
|
||||
let key = crate::utils::get_env_name("cache_dir");
|
||||
let previous = env::var_os(&key);
|
||||
let nanos = time::SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = env::temp_dir().join(format!("coyote-mixin-wrap-cache-{nanos}"));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
unsafe {
|
||||
env::set_var(&key, &path);
|
||||
}
|
||||
Self {
|
||||
key,
|
||||
previous,
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestCacheDirGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
match &self.previous {
|
||||
Some(v) => env::set_var(&self.key, v),
|
||||
None => env::remove_var(&self.key),
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_mixin(name: &str, content: &str) -> PathBuf {
|
||||
let root = unique_root(&format!("wrap-src-{name}"));
|
||||
let path = root.join("sbx-mixin.yaml");
|
||||
fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn wrap_mixin_as_kit_creates_spec_yaml_with_original_content() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let content = "schemaVersion: \"1\"\nkind: mixin\nname: probe\n";
|
||||
let mixin = write_mixin("content", content);
|
||||
|
||||
let kit_dir = wrap_mixin_as_kit(&mixin).unwrap();
|
||||
let spec = kit_dir.join("spec.yaml");
|
||||
|
||||
assert!(spec.exists(), "spec.yaml must exist in wrapped kit dir");
|
||||
assert_eq!(fs::read_to_string(&spec).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn wrap_mixin_as_kit_is_deterministic_for_identical_content() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let content = "schemaVersion: \"1\"\nkind: mixin\nname: probe\n";
|
||||
let mixin_one = write_mixin("dedup-1", content);
|
||||
let mixin_two = write_mixin("dedup-2", content);
|
||||
|
||||
let kit_a = wrap_mixin_as_kit(&mixin_one).unwrap();
|
||||
let kit_b = wrap_mixin_as_kit(&mixin_two).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
kit_a, kit_b,
|
||||
"same content should share the same content-addressed kit dir"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn wrap_mixin_as_kit_different_content_yields_different_dirs() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let mixin_a = write_mixin("diff-a", "kind: mixin\nname: a\n");
|
||||
let mixin_b = write_mixin("diff-b", "kind: mixin\nname: b\n");
|
||||
|
||||
let kit_a = wrap_mixin_as_kit(&mixin_a).unwrap();
|
||||
let kit_b = wrap_mixin_as_kit(&mixin_b).unwrap();
|
||||
|
||||
assert_ne!(
|
||||
kit_a, kit_b,
|
||||
"different content must hash to different kit dirs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn wrap_mixin_as_kit_is_idempotent_on_cache_hit() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let mixin = write_mixin("idempotent", "kind: mixin\nname: probe\n");
|
||||
|
||||
let kit_first = wrap_mixin_as_kit(&mixin).unwrap();
|
||||
let spec = kit_first.join("spec.yaml");
|
||||
let mtime_first = fs::metadata(&spec).unwrap().modified().unwrap();
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
|
||||
let kit_second = wrap_mixin_as_kit(&mixin).unwrap();
|
||||
let mtime_second = fs::metadata(kit_second.join("spec.yaml"))
|
||||
.unwrap()
|
||||
.modified()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(kit_first, kit_second);
|
||||
assert_eq!(
|
||||
mtime_first, mtime_second,
|
||||
"cache hit must not rewrite spec.yaml"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn kit_path_passes_through_existing_directory() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let dir = unique_root("kit-path-dir-passthrough");
|
||||
|
||||
let m = DiscoveredMixin {
|
||||
path: dir.clone(),
|
||||
label: "vault".into(),
|
||||
install_count: 1,
|
||||
domain_count: 1,
|
||||
};
|
||||
|
||||
assert_eq!(m.kit_path().unwrap(), dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn kit_path_wraps_file_into_kit_dir() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let mixin = write_mixin("kit-path-wrap", "kind: mixin\nname: probe\n");
|
||||
|
||||
let m = DiscoveredMixin {
|
||||
path: mixin.clone(),
|
||||
label: mixin.display().to_string(),
|
||||
install_count: 0,
|
||||
domain_count: 0,
|
||||
};
|
||||
|
||||
let wrapped = m.kit_path().unwrap();
|
||||
assert!(wrapped.is_dir(), "kit_path of a file should be a directory");
|
||||
assert!(wrapped.join("spec.yaml").exists());
|
||||
assert_ne!(
|
||||
wrapped, mixin,
|
||||
"kit_path should not return the original file path"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use rust_embed::RustEmbed;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use which::which;
|
||||
|
||||
mod mixins;
|
||||
|
||||
use gman::providers::SupportedProvider;
|
||||
|
||||
use crate::config::paths;
|
||||
use crate::sandbox::mixins::DiscoveredMixin;
|
||||
use crate::utils::run_command_with_output;
|
||||
use crate::vault::Vault;
|
||||
|
||||
const SBX_BINARY: &str = "sbx";
|
||||
pub(crate) const SANDBOX_ENV_FLAG: &str = "IS_SANDBOX";
|
||||
const SANDBOX_AGENT: &str = "coyote";
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "assets/sbx-kit/"]
|
||||
struct EmbeddedKit;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "assets/sbx-vault-mixins/"]
|
||||
struct EmbeddedVaultMixins;
|
||||
|
||||
pub fn launch(name: Option<String>, fresh: bool, no_mixins: bool) -> Result<()> {
|
||||
ensure_sbx_installed()?;
|
||||
bail_if_nested()?;
|
||||
|
||||
let name = resolve_name(name)?;
|
||||
let kit_path = resolve_kit_path()?;
|
||||
|
||||
let discovered = if no_mixins {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut all = mixins::discover()?;
|
||||
if let Ok(vault) = Vault::init_bare()
|
||||
&& let Some(vault_mixin) = extract_vault_mixin(&vault.provider)?
|
||||
{
|
||||
all.insert(0, vault_mixin);
|
||||
}
|
||||
all
|
||||
};
|
||||
|
||||
if sandbox_exists(&name)? {
|
||||
info!("Re-attaching to existing sandbox '{name}'");
|
||||
if fresh {
|
||||
debug!("--fresh ignored: re-attaching to existing sandbox '{name}'");
|
||||
}
|
||||
if no_mixins {
|
||||
debug!("--no-mixins ignored: re-attaching to existing sandbox '{name}'");
|
||||
}
|
||||
} else {
|
||||
mixins::log_discovery(&discovered, no_mixins);
|
||||
|
||||
if fresh {
|
||||
let msg = format!("Creating fresh sandbox '{name}' (no host config will be copied)");
|
||||
info!("{msg}");
|
||||
println!("{msg}");
|
||||
create_sandbox(&name, &kit_path, &discovered)?;
|
||||
} else {
|
||||
create_sandbox(&name, &kit_path, &discovered)?;
|
||||
copy_host_files(&name)?;
|
||||
}
|
||||
}
|
||||
|
||||
exec_run(&name, &kit_path)
|
||||
}
|
||||
|
||||
fn ensure_sbx_installed() -> Result<()> {
|
||||
which(SBX_BINARY).map_err(|_| {
|
||||
anyhow!(
|
||||
"`sbx` binary not found in PATH.\n\n\
|
||||
Install Docker Sandboxes:\n https://docs.docker.com/ai/sandboxes/get-started/"
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bail_if_nested() -> Result<()> {
|
||||
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
||||
bail!("Refusing to nest sandboxes: ${SANDBOX_ENV_FLAG} is set, already inside one");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_name(name: Option<String>) -> Result<String> {
|
||||
if let Some(n) = name {
|
||||
let trimmed = n.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let sanitized = sanitize_name(trimmed);
|
||||
if sanitized.is_empty() {
|
||||
bail!("Sandbox name '{trimmed}' sanitizes to an empty string");
|
||||
}
|
||||
|
||||
return Ok(sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
let cwd = env::current_dir().context("Failed to determine current directory")?;
|
||||
let basename = cwd
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| anyhow!("Could not derive sandbox name from current directory"))?;
|
||||
let sanitized = sanitize_name(basename);
|
||||
if sanitized.is_empty() {
|
||||
bail!("Could not derive a valid sandbox name from '{basename}'; pass --sandbox <NAME>");
|
||||
}
|
||||
|
||||
Ok(sanitized)
|
||||
}
|
||||
|
||||
fn sanitize_name(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut last_was_dash = false;
|
||||
for ch in input.chars() {
|
||||
let lower = ch.to_ascii_lowercase();
|
||||
if lower.is_ascii_alphanumeric() {
|
||||
out.push(lower);
|
||||
last_was_dash = false;
|
||||
} else if !last_was_dash {
|
||||
out.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
|
||||
out.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn resolve_kit_path() -> Result<PathBuf> {
|
||||
if let Some(path) = paths::sandbox_kit_override() {
|
||||
if !path.exists() {
|
||||
bail!(
|
||||
"$COYOTE_SANDBOX_KIT is set but path does not exist: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Using kit override from $COYOTE_SANDBOX_KIT: {}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
extract_embedded_kit()
|
||||
}
|
||||
|
||||
fn extract_embedded_kit() -> Result<PathBuf> {
|
||||
let cache_root = paths::sbx_kit_dir();
|
||||
let new_hash = compute_kit_hash()?;
|
||||
let hash_file = paths::sbx_kit_hash_file();
|
||||
if let Ok(existing) = fs::read_to_string(&hash_file)
|
||||
&& existing == new_hash
|
||||
{
|
||||
return Ok(cache_root);
|
||||
}
|
||||
|
||||
if cache_root.exists() {
|
||||
fs::remove_dir_all(&cache_root)
|
||||
.with_context(|| format!("Failed to clear stale kit at {}", cache_root.display()))?;
|
||||
}
|
||||
fs::create_dir_all(&cache_root)
|
||||
.with_context(|| format!("Failed to create {}", cache_root.display()))?;
|
||||
|
||||
for entry in EmbeddedKit::iter() {
|
||||
let file = EmbeddedKit::get(&entry)
|
||||
.ok_or_else(|| anyhow!("Embedded kit file missing during extraction: {entry}"))?;
|
||||
let dest = cache_root.join(entry.as_ref());
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
||||
}
|
||||
|
||||
fs::write(&dest, &file.data)
|
||||
.with_context(|| format!("Failed to write {}", dest.display()))?;
|
||||
}
|
||||
|
||||
fs::write(&hash_file, &new_hash)
|
||||
.with_context(|| format!("Failed to write {}", hash_file.display()))?;
|
||||
debug!("Extracted embedded sbx-kit to {}", cache_root.display());
|
||||
|
||||
Ok(cache_root)
|
||||
}
|
||||
|
||||
fn compute_kit_hash() -> Result<String> {
|
||||
let mut hasher = Sha256::new();
|
||||
let mut entries: Vec<_> = EmbeddedKit::iter().collect();
|
||||
entries.sort();
|
||||
|
||||
for entry in &entries {
|
||||
let file = EmbeddedKit::get(entry)
|
||||
.ok_or_else(|| anyhow!("Embedded kit file missing during hash: {entry}"))?;
|
||||
hasher.update(entry.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(&file.data);
|
||||
}
|
||||
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
fn extract_vault_mixin(provider: &SupportedProvider) -> Result<Option<DiscoveredMixin>> {
|
||||
let provider_dir = match provider {
|
||||
SupportedProvider::Local { .. } => return Ok(None),
|
||||
SupportedProvider::AwsSecretsManager { .. } => "aws_secrets_manager",
|
||||
SupportedProvider::GcpSecretManager { .. } => "gcp_secret_manager",
|
||||
SupportedProvider::AzureKeyVault { .. } => "azure_key_vault",
|
||||
SupportedProvider::Gopass { .. } => "gopass",
|
||||
SupportedProvider::OnePassword { .. } => "one_password",
|
||||
};
|
||||
|
||||
let cache_root = extract_vault_mixins_cache()?;
|
||||
let provider_root = cache_root.join(provider_dir);
|
||||
let spec_path = provider_root.join("spec.yaml");
|
||||
|
||||
if !spec_path.exists() {
|
||||
bail!(
|
||||
"Embedded vault mixin for '{provider_dir}' is missing spec.yaml at {}",
|
||||
spec_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let label = format!("<built-in: vault-{provider_dir}>");
|
||||
let (install_count, domain_count) = mixins::summarize(&spec_path)?;
|
||||
|
||||
Ok(Some(DiscoveredMixin {
|
||||
path: provider_root,
|
||||
label,
|
||||
install_count,
|
||||
domain_count,
|
||||
}))
|
||||
}
|
||||
|
||||
fn extract_vault_mixins_cache() -> Result<PathBuf> {
|
||||
let cache_root = paths::sbx_vault_mixins_dir();
|
||||
let new_hash = compute_vault_mixins_hash()?;
|
||||
let hash_file = paths::sbx_vault_mixins_hash_file();
|
||||
if let Ok(existing) = fs::read_to_string(&hash_file)
|
||||
&& existing == new_hash
|
||||
{
|
||||
return Ok(cache_root);
|
||||
}
|
||||
|
||||
if cache_root.exists() {
|
||||
fs::remove_dir_all(&cache_root).with_context(|| {
|
||||
format!(
|
||||
"Failed to clear stale vault mixins at {}",
|
||||
cache_root.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::create_dir_all(&cache_root)
|
||||
.with_context(|| format!("Failed to create {}", cache_root.display()))?;
|
||||
|
||||
for entry in EmbeddedVaultMixins::iter() {
|
||||
let file = EmbeddedVaultMixins::get(&entry).ok_or_else(|| {
|
||||
anyhow!("Embedded vault mixin file missing during extraction: {entry}")
|
||||
})?;
|
||||
let dest = cache_root.join(entry.as_ref());
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
||||
}
|
||||
|
||||
fs::write(&dest, &file.data)
|
||||
.with_context(|| format!("Failed to write {}", dest.display()))?;
|
||||
}
|
||||
|
||||
fs::write(&hash_file, &new_hash)
|
||||
.with_context(|| format!("Failed to write {}", hash_file.display()))?;
|
||||
debug!(
|
||||
"Extracted embedded sbx-vault-mixins to {}",
|
||||
cache_root.display()
|
||||
);
|
||||
|
||||
Ok(cache_root)
|
||||
}
|
||||
|
||||
fn compute_vault_mixins_hash() -> Result<String> {
|
||||
let mut hasher = Sha256::new();
|
||||
let mut entries: Vec<_> = EmbeddedVaultMixins::iter().collect();
|
||||
entries.sort();
|
||||
|
||||
for entry in &entries {
|
||||
let file = EmbeddedVaultMixins::get(entry)
|
||||
.ok_or_else(|| anyhow!("Embedded vault mixin file missing during hash: {entry}"))?;
|
||||
hasher.update(entry.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(&file.data);
|
||||
}
|
||||
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
fn sandbox_exists(name: &str) -> Result<bool> {
|
||||
let (success, stdout, stderr) =
|
||||
run_command_with_output(SBX_BINARY, &["ls"], None).context("Failed to run `sbx ls`")?;
|
||||
if !success {
|
||||
bail!("`sbx ls` failed: {stderr}");
|
||||
}
|
||||
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.skip(1)
|
||||
.any(|line| line.split_whitespace().next() == Some(name)))
|
||||
}
|
||||
|
||||
fn create_sandbox(name: &str, kit_path: &Path, mixins: &[DiscoveredMixin]) -> Result<()> {
|
||||
info!("Creating sandbox '{name}'");
|
||||
let args = build_create_args(name, kit_path, mixins)?;
|
||||
let status = Command::new(SBX_BINARY)
|
||||
.args(&args)
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.status()
|
||||
.context("Failed to spawn `sbx create`")?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("`sbx create` exited with {status}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_create_args(
|
||||
name: &str,
|
||||
kit_path: &Path,
|
||||
mixins: &[DiscoveredMixin],
|
||||
) -> Result<Vec<String>> {
|
||||
let kit_str = kit_path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Kit path is not valid UTF-8: {}", kit_path.display()))?;
|
||||
|
||||
let mut args = vec![
|
||||
"create".to_string(),
|
||||
"--kit".to_string(),
|
||||
kit_str.to_string(),
|
||||
];
|
||||
|
||||
for mixin in mixins {
|
||||
let mixin_kit = mixin.kit_path()?;
|
||||
let mixin_str = mixin_kit
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Mixin kit path is not valid UTF-8: {}", mixin_kit.display()))?
|
||||
.to_string();
|
||||
args.push("--kit".to_string());
|
||||
args.push(mixin_str);
|
||||
}
|
||||
|
||||
args.push(SANDBOX_AGENT.to_string());
|
||||
args.push("--name".to_string());
|
||||
args.push(name.to_string());
|
||||
args.push(".".to_string());
|
||||
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn copy_host_files(name: &str) -> Result<()> {
|
||||
let config_dir = paths::config_dir();
|
||||
let home_dir = dirs::home_dir().context("Could not determine home directory")?;
|
||||
|
||||
if config_dir.exists() {
|
||||
ensure_sandbox_dir(name, "/home/agent/.config")?;
|
||||
let src = format!("{}/", config_dir.display());
|
||||
let dest = format!("{name}:/home/agent/.config/");
|
||||
sbx_cp(&src, &dest)?;
|
||||
} else {
|
||||
debug!(
|
||||
"Skipping config copy: {} does not exist",
|
||||
config_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
match resolve_vault_password_file() {
|
||||
Some(password_file) if password_file.exists() => {
|
||||
let dest_path = host_to_sandbox_path(&password_file, &home_dir, cfg!(windows))?;
|
||||
if let Some(parent) = sandbox_path_parent(&dest_path)
|
||||
&& !parent.is_empty()
|
||||
{
|
||||
ensure_sandbox_dir(name, parent)?;
|
||||
}
|
||||
let dest = format!("{name}:{dest_path}");
|
||||
sbx_cp(&password_file.display().to_string(), &dest)?;
|
||||
}
|
||||
Some(password_file) => {
|
||||
debug!(
|
||||
"Skipping vault password copy: {} does not exist",
|
||||
password_file.display()
|
||||
);
|
||||
}
|
||||
None => {
|
||||
debug!("Skipping vault password copy: no local vault provider configured");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn host_to_sandbox_path(
|
||||
host_path: &Path,
|
||||
home_dir: &Path,
|
||||
is_windows_host: bool,
|
||||
) -> Result<String> {
|
||||
let host_str = host_path.to_str().context("Host path is not valid UTF-8")?;
|
||||
let home_str = home_dir
|
||||
.to_str()
|
||||
.context("Home directory is not valid UTF-8")?;
|
||||
|
||||
if let Some(rel) = strip_host_home(host_str, home_str) {
|
||||
let unixified = rel.replace('\\', "/");
|
||||
return Ok(format!("/home/agent/{unixified}"));
|
||||
}
|
||||
|
||||
if is_windows_host {
|
||||
bail!(
|
||||
"Path '{host_str}' is outside your Windows user profile ({home_str}). \
|
||||
Sandbox mode cannot copy files from outside %USERPROFILE% into a Linux \
|
||||
sandbox. Move the file under your user profile and update your config \
|
||||
accordingly."
|
||||
);
|
||||
}
|
||||
|
||||
Ok(host_str.to_string())
|
||||
}
|
||||
|
||||
fn strip_host_home(path: &str, home: &str) -> Option<String> {
|
||||
let path_norm: String = path
|
||||
.chars()
|
||||
.map(|c| if c == '\\' { '/' } else { c })
|
||||
.collect();
|
||||
let home_norm: String = home
|
||||
.chars()
|
||||
.map(|c| if c == '\\' { '/' } else { c })
|
||||
.collect();
|
||||
let home_norm = home_norm.trim_end_matches('/');
|
||||
|
||||
if home_norm.is_empty() || path_norm.len() <= home_norm.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (head, tail) = path_norm.split_at(home_norm.len());
|
||||
if head != home_norm || !tail.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(tail[1..].to_string())
|
||||
}
|
||||
|
||||
fn sandbox_path_parent(linux_path: &str) -> Option<&str> {
|
||||
linux_path.rsplit_once('/').map(|(parent, _)| parent)
|
||||
}
|
||||
|
||||
fn ensure_sandbox_dir(sandbox: &str, dir: &str) -> Result<()> {
|
||||
let dir_q = shell_words::quote(dir);
|
||||
let cmd = format!("sudo mkdir -p {dir_q} && sudo chown agent:agent {dir_q}");
|
||||
|
||||
debug!("sbx exec {sandbox}: {cmd}");
|
||||
|
||||
let status = Command::new(SBX_BINARY)
|
||||
.args(["exec", sandbox, "sh", "-c", &cmd])
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.status()
|
||||
.context("Failed to spawn `sbx exec` to prepare destination directory")?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("Preparing sandbox directory '{dir}' failed: sbx exec exited with {status}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_vault_password_file() -> Option<PathBuf> {
|
||||
Vault::init_bare().ok()?.local_password_file().ok()
|
||||
}
|
||||
|
||||
fn sbx_cp(src: &str, dest: &str) -> Result<()> {
|
||||
debug!("sbx cp {src} {dest}");
|
||||
let status = Command::new(SBX_BINARY)
|
||||
.args(["cp", src, dest])
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.status()
|
||||
.context("Failed to spawn `sbx cp`")?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("`sbx cp {src} {dest}` exited with {status}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn exec_run(name: &str, kit_path: &Path) -> Result<()> {
|
||||
let kit_str = kit_path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Kit path is not valid UTF-8: {}", kit_path.display()))?;
|
||||
let status = Command::new(SBX_BINARY)
|
||||
.args(["run", name, "--kit", kit_str])
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.status()
|
||||
.context("Failed to spawn `sbx run`")?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("`sbx run` exited with {status}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_name_lowercases() {
|
||||
assert_eq!(sanitize_name("Foo"), "foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_name_replaces_non_alphanumeric() {
|
||||
assert_eq!(sanitize_name("hello world!"), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_name_collapses_dash_runs() {
|
||||
assert_eq!(sanitize_name("a___b"), "a-b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_name_trims_dashes() {
|
||||
assert_eq!(sanitize_name("---hi---"), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_name_handles_mixed_input() {
|
||||
assert_eq!(sanitize_name("My Project (v2)"), "my-project-v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_name_all_invalid_yields_empty() {
|
||||
assert_eq!(sanitize_name("///"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_name_uses_explicit_arg() {
|
||||
let n = resolve_name(Some("explicit-name".to_string())).unwrap();
|
||||
assert_eq!(n, "explicit-name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_name_sanitizes_explicit_arg() {
|
||||
let n = resolve_name(Some("My Sandbox!".to_string())).unwrap();
|
||||
assert_eq!(n, "my-sandbox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_name_rejects_empty_after_sanitize() {
|
||||
let err = resolve_name(Some("///".to_string()));
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_name_falls_back_to_cwd_when_none() {
|
||||
let n = resolve_name(None).unwrap();
|
||||
assert!(!n.is_empty());
|
||||
assert!(n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_kit_hash_is_deterministic() {
|
||||
let h1 = compute_kit_hash().unwrap();
|
||||
let h2 = compute_kit_hash().unwrap();
|
||||
|
||||
assert_eq!(h1, h2);
|
||||
assert_eq!(h1.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_create_args_emits_base_kit_before_mixins() {
|
||||
let kit = PathBuf::from("/cache/sbx-kit");
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir_a = env::temp_dir().join(format!("coyote-mixin-a-{unique}"));
|
||||
let dir_b = env::temp_dir().join(format!("coyote-mixin-b-{unique}"));
|
||||
fs::create_dir_all(&dir_a).unwrap();
|
||||
fs::create_dir_all(&dir_b).unwrap();
|
||||
|
||||
let mixins = vec![
|
||||
DiscoveredMixin {
|
||||
path: dir_a.clone(),
|
||||
label: "user".into(),
|
||||
install_count: 0,
|
||||
domain_count: 0,
|
||||
},
|
||||
DiscoveredMixin {
|
||||
path: dir_b.clone(),
|
||||
label: "sql".into(),
|
||||
install_count: 0,
|
||||
domain_count: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let args = build_create_args("my-box", &kit, &mixins).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"create".to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cache/sbx-kit".to_string(),
|
||||
"--kit".to_string(),
|
||||
dir_a.display().to_string(),
|
||||
"--kit".to_string(),
|
||||
dir_b.display().to_string(),
|
||||
"coyote".to_string(),
|
||||
"--name".to_string(),
|
||||
"my-box".to_string(),
|
||||
".".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir_a);
|
||||
let _ = fs::remove_dir_all(&dir_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_create_args_with_no_mixins_omits_mixin_kits() {
|
||||
let kit = PathBuf::from("/cache/sbx-kit");
|
||||
let args = build_create_args("box", &kit, &[]).unwrap();
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"create".to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cache/sbx-kit".to_string(),
|
||||
"coyote".to_string(),
|
||||
"--name".to_string(),
|
||||
"box".to_string(),
|
||||
".".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
mod vault_mixins {
|
||||
use super::*;
|
||||
use crate::utils::get_env_name;
|
||||
use gman::providers::aws_secrets_manager::AwsSecretsManagerProvider;
|
||||
use gman::providers::azure_key_vault::AzureKeyVaultProvider;
|
||||
use gman::providers::gcp_secret_manager::GcpSecretManagerProvider;
|
||||
use gman::providers::gopass::GopassProvider;
|
||||
use gman::providers::local::LocalProvider;
|
||||
use gman::providers::one_password::OnePasswordProvider;
|
||||
use serial_test::serial;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct TestCacheDirGuard {
|
||||
key: String,
|
||||
previous: Option<std::ffi::OsString>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestCacheDirGuard {
|
||||
fn new() -> Self {
|
||||
let key = get_env_name("cache_dir");
|
||||
let previous = env::var_os(&key);
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = env::temp_dir().join(format!("coyote-sandbox-vault-tests-{unique}"));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
unsafe {
|
||||
env::set_var(&key, &path);
|
||||
}
|
||||
Self {
|
||||
key,
|
||||
previous,
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestCacheDirGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
match &self.previous {
|
||||
Some(v) => env::set_var(&self.key, v),
|
||||
None => env::remove_var(&self.key),
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_local() {
|
||||
let p = SupportedProvider::Local {
|
||||
provider_def: LocalProvider::default(),
|
||||
};
|
||||
assert!(extract_vault_mixin(&p).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_aws() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let p = SupportedProvider::AwsSecretsManager {
|
||||
provider_def: AwsSecretsManagerProvider {
|
||||
aws_profile: None,
|
||||
aws_region: None,
|
||||
},
|
||||
};
|
||||
let m = extract_vault_mixin(&p)
|
||||
.unwrap()
|
||||
.expect("expected vault mixin");
|
||||
assert!(m.path.join("spec.yaml").exists());
|
||||
assert!(m.label.contains("aws_secrets_manager"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_gcp() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let p = SupportedProvider::GcpSecretManager {
|
||||
provider_def: GcpSecretManagerProvider {
|
||||
gcp_project_id: None,
|
||||
},
|
||||
};
|
||||
let m = extract_vault_mixin(&p)
|
||||
.unwrap()
|
||||
.expect("expected vault mixin");
|
||||
assert!(m.path.join("spec.yaml").exists());
|
||||
assert!(m.label.contains("gcp_secret_manager"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_one_password() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let p = SupportedProvider::OnePassword {
|
||||
provider_def: OnePasswordProvider {
|
||||
vault: None,
|
||||
account: None,
|
||||
},
|
||||
};
|
||||
let m = extract_vault_mixin(&p)
|
||||
.unwrap()
|
||||
.expect("expected vault mixin");
|
||||
assert!(m.path.join("spec.yaml").exists());
|
||||
assert!(m.label.contains("one_password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_azure() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let p = SupportedProvider::AzureKeyVault {
|
||||
provider_def: AzureKeyVaultProvider { vault_name: None },
|
||||
};
|
||||
let m = extract_vault_mixin(&p)
|
||||
.unwrap()
|
||||
.expect("expected vault mixin");
|
||||
assert!(m.path.join("spec.yaml").exists());
|
||||
assert!(m.label.contains("azure_key_vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn returns_some_for_gopass() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let p = SupportedProvider::Gopass {
|
||||
provider_def: GopassProvider { store: None },
|
||||
};
|
||||
let m = extract_vault_mixin(&p)
|
||||
.unwrap()
|
||||
.expect("expected vault mixin");
|
||||
assert!(m.path.join("spec.yaml").exists());
|
||||
assert!(m.label.contains("gopass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_is_deterministic() {
|
||||
let h1 = compute_vault_mixins_hash().unwrap();
|
||||
let h2 = compute_vault_mixins_hash().unwrap();
|
||||
assert_eq!(h1, h2);
|
||||
assert_eq!(h1.len(), 64);
|
||||
}
|
||||
}
|
||||
|
||||
mod host_to_sandbox_path_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn linux_under_home() {
|
||||
let dest = host_to_sandbox_path(
|
||||
Path::new("/home/atusa/.coyote_password"),
|
||||
Path::new("/home/atusa"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dest, "/home/agent/.coyote_password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_nested_under_home() {
|
||||
let dest = host_to_sandbox_path(
|
||||
Path::new("/home/atusa/.config/coyote/.password"),
|
||||
Path::new("/home/atusa"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dest, "/home/agent/.config/coyote/.password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_outside_home_returns_verbatim() {
|
||||
let dest = host_to_sandbox_path(
|
||||
Path::new("/etc/coyote/.password"),
|
||||
Path::new("/home/atusa"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dest, "/etc/coyote/.password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_under_home_with_spaces() {
|
||||
let dest = host_to_sandbox_path(
|
||||
Path::new("/Users/atusa/Library/Application Support/coyote/.password"),
|
||||
Path::new("/Users/atusa"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
dest,
|
||||
"/home/agent/Library/Application Support/coyote/.password"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_under_home_converts_backslashes() {
|
||||
let dest = host_to_sandbox_path(
|
||||
Path::new(r"C:\Users\atusa\.coyote_password"),
|
||||
Path::new(r"C:\Users\atusa"),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dest, "/home/agent/.coyote_password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_nested_under_home() {
|
||||
let dest = host_to_sandbox_path(
|
||||
Path::new(r"C:\Users\atusa\Documents\my\vault.txt"),
|
||||
Path::new(r"C:\Users\atusa"),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dest, "/home/agent/Documents/my/vault.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_outside_home_bails_with_clear_error() {
|
||||
let err = host_to_sandbox_path(
|
||||
Path::new(r"C:\Program Files\Coyote\vault.txt"),
|
||||
Path::new(r"C:\Users\atusa"),
|
||||
true,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("Program Files"),
|
||||
"error should name the offending path: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("user profile"),
|
||||
"error should explain the limitation: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_tolerates_trailing_slash_in_home() {
|
||||
let dest = host_to_sandbox_path(
|
||||
Path::new(r"C:\Users\atusa\foo"),
|
||||
Path::new(r"C:\Users\atusa\"),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dest, "/home/agent/foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_path_parent_extracts_parent_for_nested() {
|
||||
assert_eq!(
|
||||
sandbox_path_parent("/home/agent/.coyote_password"),
|
||||
Some("/home/agent")
|
||||
);
|
||||
assert_eq!(
|
||||
sandbox_path_parent("/etc/coyote/.password"),
|
||||
Some("/etc/coyote")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_path_parent_handles_edge_cases() {
|
||||
assert_eq!(sandbox_path_parent("/file"), Some(""));
|
||||
assert_eq!(sandbox_path_parent("noparent"), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod taskqueue;
|
||||
use crate::utils::AbortSignal;
|
||||
use fmt::{Debug, Formatter};
|
||||
use mailbox::Inbox;
|
||||
use parking_lot::RwLock;
|
||||
use taskqueue::TaskQueue;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
@@ -33,6 +34,7 @@ pub struct AgentHandle {
|
||||
pub inbox: Arc<Inbox>,
|
||||
pub abort_signal: AbortSignal,
|
||||
pub join_handle: JoinHandle<Result<AgentResult>>,
|
||||
pub child_supervisor: Option<Arc<RwLock<Supervisor>>>,
|
||||
}
|
||||
|
||||
pub struct Supervisor {
|
||||
@@ -103,6 +105,10 @@ impl Supervisor {
|
||||
self.handles.get(id).map(|h| &h.inbox)
|
||||
}
|
||||
|
||||
pub fn abort_signal_for(&self, id: &str) -> Option<AbortSignal> {
|
||||
self.handles.get(id).map(|h| h.abort_signal.clone())
|
||||
}
|
||||
|
||||
pub fn list_agents(&self) -> Vec<(&str, &str)> {
|
||||
self.handles
|
||||
.values()
|
||||
@@ -115,6 +121,15 @@ impl Supervisor {
|
||||
handle.abort_signal.set_ctrlc();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_recursive(&self) {
|
||||
for handle in self.handles.values() {
|
||||
handle.abort_signal.set_ctrlc();
|
||||
if let Some(child_sup) = handle.child_supervisor.as_ref() {
|
||||
child_sup.read().cancel_recursive();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for Supervisor {
|
||||
@@ -152,6 +167,7 @@ mod tests {
|
||||
inbox: Arc::new(Inbox::new()),
|
||||
abort_signal: create_abort_signal(),
|
||||
join_handle,
|
||||
child_supervisor: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-1
@@ -17,7 +17,7 @@ use gman::providers::SecretProvider;
|
||||
use gman::providers::SupportedProvider;
|
||||
use gman::providers::local::LocalProvider;
|
||||
use inquire::{Password, PasswordDisplayMode, required};
|
||||
use log::warn;
|
||||
use log::{info, warn};
|
||||
use serde_yaml::Value;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tokio::runtime::Handle;
|
||||
@@ -25,6 +25,31 @@ use uuid::Uuid;
|
||||
|
||||
pub static SECRET_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{([^{}]+)}}").unwrap());
|
||||
|
||||
fn apply_sandboxed_home_translation(provider_def: &mut LocalProvider) {
|
||||
let Some(ref pf) = provider_def.password_file else {
|
||||
return;
|
||||
};
|
||||
|
||||
if pf.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(translated) = paths::translate_sandboxed_home_path(pf) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !translated.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
"vault password file '{}' not found; resolved to sandboxed path '{}'",
|
||||
pf.display(),
|
||||
translated.display()
|
||||
);
|
||||
provider_def.password_file = Some(translated);
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Vault {
|
||||
pub(crate) provider: SupportedProvider,
|
||||
@@ -92,6 +117,7 @@ impl Vault {
|
||||
};
|
||||
|
||||
if let SupportedProvider::Local { provider_def } = &mut provider {
|
||||
apply_sandboxed_home_translation(provider_def);
|
||||
ensure_password_file_initialized(provider_def)?;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user