Compare commits
32 Commits
skills
...
b927e2a200
| Author | SHA1 | Date | |
|---|---|---|---|
|
b927e2a200
|
|||
|
6ce69ee989
|
|||
|
dc6d736df3
|
|||
|
2a79616f8b
|
|||
|
eb6a02f947
|
|||
|
00939e4634
|
|||
|
6ebd32d47c
|
|||
|
73c4449e7f
|
|||
|
7143b50d98
|
|||
|
de38e663a0
|
|||
|
10de6025b5
|
|||
|
0d2292bff6
|
|||
|
eb38ca0bbb
|
|||
|
1931331163
|
|||
|
218750cc1e
|
|||
|
a10b23dbc1
|
|||
|
19d2340489
|
|||
|
4ece3d3df1
|
|||
|
6d5cbfa56d
|
|||
|
7e097e0465
|
|||
|
b2d70a3fd3
|
|||
|
3183fedca9
|
|||
|
33c6f2c4e3
|
|||
| bca25404ab | |||
| 161fa2d983 | |||
| 0e93775491 | |||
| c00c4ff84a | |||
| 46685cb641 | |||
| 165d0d113d | |||
| 70dc7c9680 | |||
| 4eac536327 | |||
| 8e0fa79ff3 |
@@ -1,3 +1,84 @@
|
||||
## v0.6.0 (2026-06-05)
|
||||
|
||||
### Feat
|
||||
|
||||
- added skill hint prompt injection and configuration
|
||||
- Fallthrough on missing secrets during mcp.json merging
|
||||
- validate visible_skills field at config load time
|
||||
- implemented reflexion (sorta) in sisyphus for significant code changes to delegate to the code-reviewer agent
|
||||
- improved explore agent
|
||||
- removed conditional fallback of LLM_*_RAW_JSON from built-ins
|
||||
- updated enabled_skills handling to support both list and comma-separated strings
|
||||
- added new REPL set commands for toggling skills and changing what skills are enabled
|
||||
- upgraded to the latest version of mcp-remote
|
||||
- fs_grep now works with both files and directories
|
||||
- improved code reviewer agents with skills
|
||||
- added round trip validation for vault providers to ensure permissions and authentication
|
||||
- created new first-time run wizard for secrets provider
|
||||
- vault_password_file or nothing at all is shorthand for just using the local gman provider for secret management
|
||||
- refactored gman usage to be generic and work with various vault providers and use the SupportedProvider enum directly for configurations
|
||||
- created initial parity gman generalization for vault provider
|
||||
- Refactored the sisyhpus agent system to utilize the new skills system to improve performance and reliability
|
||||
- llm graph nodes support skills
|
||||
- updated sisyphus and coder tools
|
||||
- removed potentially confusing tab completions for .skill
|
||||
- .edit skill <name> support from within the REPL
|
||||
- Added skills_dir to the info output of Coyote
|
||||
- Created a few auto built-in skills
|
||||
- Added support for auto_unload skills during chat
|
||||
- cleaned up skill implementation
|
||||
- support multiple skill flags to load multiple skills at CLI startup
|
||||
- Modified --skill CLI to allow users to specify skills to start the REPL or CLI with.
|
||||
- added CLI --skill flag for modifying skills easily
|
||||
- REPL integration with skills
|
||||
- dynamic loading/unloading of skill tools and MCP servers whenever load_skill/unload_skill are invoked
|
||||
- created built-in functions for listing, loading, and unloading skills
|
||||
- implemented the skills policy to track available skills per context
|
||||
- added remote install and install support for skills
|
||||
- created the skill registry
|
||||
- decided to make skills persist to disk like agents and not in-memory like built-in roles
|
||||
- scaffold skill module
|
||||
|
||||
### Fix
|
||||
|
||||
- disable skills for specific built-in roles
|
||||
- redirect stderr into user's /dev/tty for guards
|
||||
- azure doesn't support underscores in key vault
|
||||
- accidental regression on enabled_skills being empty = all
|
||||
- greedy secrets regex caused multiple secrets on one line to fail
|
||||
- add agent context check to skill visibility validation
|
||||
- enforced global visible_skills in llm node validation and improved skill loading error handling across the project
|
||||
- restore agent skill policy on error during effective policy calculation
|
||||
- apply the same validation for skill filenames on list_skills as happens everywhere else
|
||||
- the vault's init_bare should try to load the provisioned secret_provider from the config file without also interpolating any of the rest of the configuration file. It should only fail if the user has not yet created a configuration file; i.e. done a first-time run.
|
||||
- the vault roundtrip test used characters that are unsupported by some major secrets providers
|
||||
- fixed tool filtering logic for skills and user functions in agents
|
||||
- privilege leak when unloading skills and leaving tool scope untouched
|
||||
- When bootstrapping an app config to interpolate secrets, clone the secrets provider configuration as well so config secrets stored in remote vaults can be used properly
|
||||
- forgot to move back up the vault probe value error to be before the delete
|
||||
- don't silently fail on skill role composition extraction in llm nodes
|
||||
- set -euo pipefail for the temp script in execute_command.sh tool
|
||||
- added forgotten skill name validation to has_skill to prevent side-channel attacks
|
||||
- use unique values for the secrets round trip verification
|
||||
- stop interpolating a line if any errors occur
|
||||
- added path validation for skill names
|
||||
- effective_policy unconditionally overwrote skill values for role-like structs
|
||||
- updated execute_command to not mangle heredocs and also added explicit instructions to the coder and sisyphus agents to use fs_write and fs_patch over execute_command when writing files
|
||||
- llm nodes accidentally skipped skill_registry::effective_role because I was passing an inline role instead
|
||||
- updated temperature values for all agents and roles
|
||||
- added back in require_max_tokens for new Claude models
|
||||
- skill support also requires function calling to be enabled
|
||||
- non_tty tests break on some TTY terminals
|
||||
- skill loading on agents
|
||||
- forgot to bootstrap skills on REPL startup
|
||||
- remove now deprecated .skill edit command
|
||||
|
||||
### Refactor
|
||||
|
||||
- removed redundant skill name validation from has_skill function
|
||||
- support both CSV and list formats for enabled_tools
|
||||
- Support both CSV and list formats for enabled_mcp_servers
|
||||
|
||||
## v0.5.0 (2026-05-27)
|
||||
|
||||
### Feat
|
||||
|
||||
Generated
+162
-167
@@ -278,9 +278,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-config"
|
||||
version = "1.8.17"
|
||||
version = "1.8.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "517aa062d8bd9015ee23d6daa5e1c1372328412fdae4e6c4c1be9b69c6ad37a2"
|
||||
checksum = "e33f815b73a3899c03b380d543532e5865f230dce9678d108dc10732a8682275"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
@@ -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",
|
||||
@@ -381,10 +381,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-secretsmanager"
|
||||
version = "1.105.0"
|
||||
version = "1.107.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c4e56ac810211dc33810c7aa3612eda29a8b1e8c7e2db6e960c8657e3d95e42"
|
||||
checksum = "63da8ec2dca98a68d8bcba971abae5f06e2c9c0017f43097d1ff92cff96adc54"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
@@ -398,17 +399,18 @@ dependencies = [
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sso"
|
||||
version = "1.99.0"
|
||||
version = "1.101.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f4055e6099b2ec264abdc0d9bbfffce306c1601809275c861594779a0b04b45"
|
||||
checksum = "b647baea49ff551960b904f905681e9b4765a6c4ea08631e89dc52d8bd3f5896"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
@@ -422,17 +424,18 @@ dependencies = [
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-ssooidc"
|
||||
version = "1.101.0"
|
||||
version = "1.103.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02f009ba0284c5d696425fd7b4dcc5b189f5726f4041b7a5794daecb3a68d598"
|
||||
checksum = "7ae401c65ff288aa7873117fe535cd32b7b1bb0bc43751d28901a1d5f20636b9"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
@@ -446,17 +449,18 @@ dependencies = [
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sts"
|
||||
version = "1.104.0"
|
||||
version = "1.106.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aa6622798e19e6a76b690562085dd4771c736cd48343464a53ab4ae2f2c9f84"
|
||||
checksum = "4c80de7bb7d03e9ca8c9fd7b489f20f3948d3f3be91a7953591347d238115408"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
@@ -471,16 +475,16 @@ dependencies = [
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sigv4"
|
||||
version = "1.4.4"
|
||||
version = "1.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7083fb918b38474ac65ffbf8a69fc8792d36879f4ac5f1667b43aec61efe9a5"
|
||||
checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-http",
|
||||
@@ -491,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",
|
||||
@@ -532,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",
|
||||
@@ -543,9 +547,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http-client"
|
||||
version = "1.1.12"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769"
|
||||
checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -553,10 +557,10 @@ 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.0",
|
||||
"hyper 1.10.1",
|
||||
"hyper-rustls 0.24.2",
|
||||
"hyper-rustls 0.27.9",
|
||||
"hyper-util",
|
||||
@@ -573,9 +577,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-json"
|
||||
version = "0.62.6"
|
||||
version = "0.62.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "517089205f18ab4adc5a3e02888cb139bbbbb2e168eac9f396216925d1fbeaf5"
|
||||
checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
@@ -617,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",
|
||||
@@ -629,16 +633,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime-api"
|
||||
version = "1.12.1"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc117c179ecf39a62a0a3f49f600e9ac26a7ad7dd172177999f83933af776c32"
|
||||
checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api-macros",
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
"http 0.2.12",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -664,21 +668,21 @@ checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-types"
|
||||
version = "1.4.8"
|
||||
version = "1.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "056b66dbce2f81cc0c1e2b05bb402eb58f8a3530479d650efadd5bbae9a4050b"
|
||||
checksum = "53f93074121a1be41317b9aa607143ae17900631f7f59a99f2b905d519d6783b"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
"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",
|
||||
@@ -726,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",
|
||||
@@ -750,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",
|
||||
@@ -899,7 +903,7 @@ dependencies = [
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"shlex 1.3.0",
|
||||
"syn",
|
||||
]
|
||||
|
||||
@@ -920,9 +924,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -947,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",
|
||||
]
|
||||
@@ -1056,14 +1060,14 @@ checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.62"
|
||||
version = "1.2.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
"shlex 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1124,9 +1128,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
@@ -1190,7 +1194,7 @@ dependencies = [
|
||||
"clap",
|
||||
"clap_lex",
|
||||
"is_executable",
|
||||
"shlex",
|
||||
"shlex 1.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1241,9 +1245,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.3"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
@@ -1398,7 +1402,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "coyote-ai"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"ansi_colours",
|
||||
"anyhow",
|
||||
@@ -1407,6 +1411,7 @@ dependencies = [
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
"aws-smithy-eventstream",
|
||||
"aws-smithy-types",
|
||||
"base64",
|
||||
"bincode 2.0.1",
|
||||
"bitflags",
|
||||
@@ -1429,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",
|
||||
@@ -1462,6 +1467,7 @@ dependencies = [
|
||||
"sys-locale",
|
||||
"terminal-colorsaurus",
|
||||
"textwrap",
|
||||
"time",
|
||||
"tokio",
|
||||
"tree-sitter",
|
||||
"tree-sitter-python",
|
||||
@@ -1833,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",
|
||||
@@ -2333,7 +2339,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"hyper 1.10.0",
|
||||
"hyper 1.10.1",
|
||||
"jsonwebtoken",
|
||||
"once_cell",
|
||||
"prost",
|
||||
@@ -2503,7 +2509,7 @@ dependencies = [
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"indexmap 2.14.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
@@ -2655,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",
|
||||
@@ -2681,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]]
|
||||
@@ -2692,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",
|
||||
]
|
||||
@@ -2766,16 +2772,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.0"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2 0.4.14",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
@@ -2807,8 +2813,8 @@ version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http 1.4.1",
|
||||
"hyper 1.10.0",
|
||||
"http 1.4.2",
|
||||
"hyper 1.10.1",
|
||||
"hyper-util",
|
||||
"rustls 0.23.40",
|
||||
"rustls-native-certs",
|
||||
@@ -2823,7 +2829,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
||||
dependencies = [
|
||||
"hyper 1.10.0",
|
||||
"hyper 1.10.1",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
@@ -2838,7 +2844,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper 1.10.0",
|
||||
"hyper 1.10.1",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
@@ -2856,14 +2862,14 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"hyper 1.10.0",
|
||||
"hyper 1.10.1",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.6.4",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3153,9 +3159,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.27"
|
||||
version = "0.2.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "392c70591e8749fe235ddaf513e6f58b26bce3dcc16524cecc8936f75afa161e"
|
||||
checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
@@ -3166,9 +3172,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.27"
|
||||
version = "0.2.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b605b0c050d845fc355bb11eb3f9a8deddc218ea60c76e61aa1f2adfb2c96a"
|
||||
checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3236,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",
|
||||
]
|
||||
|
||||
@@ -3351,9 +3356,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.30"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -3473,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"
|
||||
@@ -3511,9 +3516,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -3540,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"
|
||||
@@ -3952,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",
|
||||
]
|
||||
@@ -4351,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",
|
||||
@@ -4361,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",
|
||||
@@ -4374,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",
|
||||
]
|
||||
@@ -4412,7 +4417,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.40",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.6.4",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -4450,7 +4455,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.6.4",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -4622,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",
|
||||
@@ -4651,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"
|
||||
@@ -4665,10 +4670,10 @@ 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.0",
|
||||
"hyper 1.10.1",
|
||||
"hyper-rustls 0.27.9",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
@@ -4711,10 +4716,10 @@ 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.0",
|
||||
"hyper 1.10.1",
|
||||
"hyper-rustls 0.27.9",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
@@ -4779,7 +4784,7 @@ dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"futures",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"process-wrap",
|
||||
@@ -4817,9 +4822,9 @@ checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "7.5.3"
|
||||
version = "7.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "835a57a69104632d64deb0df2e09a69945cd7a6eab4070fc9b1d7e50cf6c3edc"
|
||||
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rtoolbox",
|
||||
@@ -4944,9 +4949,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
@@ -5034,15 +5039,6 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scc"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
|
||||
dependencies = [
|
||||
"sdd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
@@ -5121,12 +5117,6 @@ dependencies = [
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sdd"
|
||||
version = "3.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
||||
|
||||
[[package]]
|
||||
name = "secrecy"
|
||||
version = "0.10.3"
|
||||
@@ -5208,7 +5198,7 @@ checksum = "2e79722b5a505d4ddc77527455a97244e9e8c4c07533ff44cf4421cce7bb6d17"
|
||||
dependencies = [
|
||||
"either",
|
||||
"flate2",
|
||||
"http 1.4.1",
|
||||
"http 1.4.2",
|
||||
"indicatif",
|
||||
"log",
|
||||
"quick-xml 0.38.4",
|
||||
@@ -5320,9 +5310,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.20.0"
|
||||
version = "3.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
|
||||
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bs58",
|
||||
@@ -5340,9 +5330,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.20.0"
|
||||
version = "3.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
|
||||
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
@@ -5365,24 +5355,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serial_test"
|
||||
version = "3.4.0"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
|
||||
checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d"
|
||||
dependencies = [
|
||||
"futures-executor",
|
||||
"futures-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"scc",
|
||||
"serial_test_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial_test_derive"
|
||||
version = "3.4.0"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
|
||||
checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5460,6 +5449,12 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "sigchld"
|
||||
version = "0.2.4"
|
||||
@@ -5560,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"
|
||||
@@ -5582,9 +5577,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -6019,7 +6014,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.6.4",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -6127,16 +6122,16 @@ 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.0",
|
||||
"hyper 1.10.1",
|
||||
"hyper-timeout",
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"rustls-native-certs",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.6.4",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
@@ -6188,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",
|
||||
@@ -6311,9 +6306,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "typespec"
|
||||
@@ -6386,9 +6381,9 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
@@ -6474,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",
|
||||
]
|
||||
@@ -6523,9 +6518,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
version = "1.23.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -6634,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",
|
||||
@@ -6647,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",
|
||||
@@ -6657,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",
|
||||
@@ -6667,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",
|
||||
@@ -6680,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",
|
||||
]
|
||||
@@ -6819,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",
|
||||
@@ -6857,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",
|
||||
]
|
||||
@@ -7418,9 +7413,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
@@ -7441,18 +7436,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.49"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b"
|
||||
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.49"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e"
|
||||
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coyote-ai"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
||||
description = "An all-in-one, batteries included LLM CLI Tool"
|
||||
@@ -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"
|
||||
|
||||
@@ -36,6 +36,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -507,7 +507,9 @@ open_link() {
|
||||
|
||||
guard_operation() {
|
||||
if [[ -z "$AUTO_CONFIRM" && -z "$LLM_AGENT_VAR_AUTO_CONFIRM" ]]; then
|
||||
ans="$(confirm "${1:-Are you sure you want to continue?}")"
|
||||
# 2>/dev/tty: keep the prompt off the host-captured stderr pipe so it
|
||||
# can't leak into tool_call_error JSON when the wrapped command fails.
|
||||
ans="$(confirm "${1:-Are you sure you want to continue?}" 2>/dev/tty)"
|
||||
|
||||
if [[ "$ans" == 0 ]]; then
|
||||
error "Operation aborted!" 2>&1
|
||||
@@ -598,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
|
||||
}
|
||||
@@ -619,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
|
||||
}
|
||||
}
|
||||
@@ -657,7 +692,8 @@ guard_path() {
|
||||
confirmation_prompt="$2"
|
||||
|
||||
if [[ ! "$path" == "$(pwd)"* && -z "$AUTO_CONFIRM" && -z "$LLM_AGENT_VAR_AUTO_CONFIRM" ]]; then
|
||||
ans="$(confirm "$confirmation_prompt")"
|
||||
# 2>/dev/tty: see guard_operation — prevents prompt text leaking via captured stderr.
|
||||
ans="$(confirm "$confirmation_prompt" 2>/dev/tty)"
|
||||
|
||||
if [[ "$ans" == 0 ]]; then
|
||||
error "Operation aborted!" >&2
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
skills_enabled: false
|
||||
---
|
||||
As a professional Prompt Engineer, your role is to create effective and innovative prompts for interacting with AI models.
|
||||
|
||||
Your core skills include:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
skills_enabled: false
|
||||
---
|
||||
Create a concise, 3-6 word title.
|
||||
|
||||
**Notes**:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
skills_enabled: false
|
||||
---
|
||||
Provide a terse, single sentence description of the given shell command.
|
||||
Describe each argument and option of the command.
|
||||
Provide short responses in about 80 words.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
---
|
||||
skills_enabled: false
|
||||
---
|
||||
Provide only {{__shell__}} commands for {{__os_distro__}} without any description.
|
||||
Ensure the output is a valid {{__shell__}} command.
|
||||
If there is a lack of details, provide most logical solution.
|
||||
|
||||
@@ -48,6 +48,12 @@ enabled_skills: # Optional list of skills available when this a
|
||||
# Must be a subset of global `visible_skills`. Omit to inherit the global default.
|
||||
- git-master
|
||||
- ai-slop-remover
|
||||
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
|
||||
You are a AI agent designed to demonstrate agent capabilities.
|
||||
|
||||
+28
-11
@@ -137,21 +137,25 @@ enabled_mcp_servers: null # Which MCP servers to enable by default.
|
||||
# ---- Skills ----
|
||||
# Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation.
|
||||
# See the [Skills documentation](https://github.com/Dark-Alex-17/coyote/wiki/Skills) for more details.
|
||||
skills_enabled: true # Master switch. Set to false to hide all skill management tools from the model.
|
||||
# Skills also require `function_calling_support: true` above to work at all.
|
||||
visible_skills: # The universe of skills allowed to be enabled in any context. Omit (null) for "all installed".
|
||||
skills_enabled: true # Master switch. Set to false to hide all skill management tools from the model.
|
||||
# Skills also require `function_calling_support: true` above to work at all.
|
||||
visible_skills: # The universe of skills allowed to be enabled in any context. Omit (null) for "all installed".
|
||||
- ai-slop-remover
|
||||
- code-review
|
||||
- frontend-ui-ux
|
||||
- git-master
|
||||
enabled_skills: null # Which skills are available by default (no role/agent/session active). null = all visible.
|
||||
# Accepts either a YAML list or a comma-separated string.
|
||||
# Example (list form):
|
||||
# enabled_skills:
|
||||
# - git-master
|
||||
# - ai-slop-remover
|
||||
# Example (comma-separated form):
|
||||
# enabled_skills: git-master,ai-slop-remover
|
||||
enabled_skills: null # Which skills are available by default (no role/agent/session active). null = all visible.
|
||||
# Accepts either a YAML list or a comma-separated string.
|
||||
# Example (list form):
|
||||
# enabled_skills:
|
||||
# - git-master
|
||||
# - ai-slop-remover
|
||||
# Example (comma-separated form):
|
||||
# enabled_skills: git-master,ai-slop-remover
|
||||
inject_skill_instructions: true # Inject a short hint pointing the model at `skill__list` when skills are enabled in
|
||||
# this context. Only injected if `function_calling_support`, `skills_enabled`, and the
|
||||
# effective enabled skill set is non-empty (default: true).
|
||||
skill_instructions: null # Custom text used for the skill hint when injected. If null, uses built-in default.
|
||||
|
||||
# ---- Auto-Continue (Todo System) ----
|
||||
# The auto-continue system provides built-in task tracking for improved reliability.
|
||||
@@ -172,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
|
||||
|
||||
@@ -19,6 +19,12 @@ skills_enabled: true # Master switch for skills in this role (d
|
||||
enabled_skills: # Skills available when this role is active. Accepts a YAML list (preferred)
|
||||
- git-master # or a comma-separated string (e.g. `enabled_skills: git-master,ai-slop-remover`).
|
||||
- ai-slop-remover # Must be a subset of global `visible_skills`. Omit to inherit the global default.
|
||||
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
|
||||
# Auto-Continue (Todo System)
|
||||
|
||||
@@ -63,6 +63,9 @@ enabled_skills:
|
||||
- code-review
|
||||
- git-master
|
||||
- ai-slop-remover
|
||||
inject_skill_instructions: true # Inject a hint pointing the model at `skill__list`. Defaults to true; suppressed
|
||||
# automatically when no skills are available.
|
||||
skill_instructions: null # Custom text for the skill hint (optional; uses the built-in default if omitted).
|
||||
|
||||
conversation_starters: # Suggested prompts surfaced in the UI
|
||||
- "Research the current state of WebAssembly outside the browser"
|
||||
@@ -173,8 +176,12 @@ nodes:
|
||||
# catches violations at load time). `skills_enabled: false` would
|
||||
# disable skills entirely for this node (no meta-tools exposed).
|
||||
# Nothing is auto-loaded: the model decides when to load a skill.
|
||||
skills_enabled: true # Whether skills are enabled on this llm node; defaults to 'true'
|
||||
enabled_skills:
|
||||
- ai-slop-remover
|
||||
inject_skill_instructions: true # Override skill-hint injection for just this node. Falls back to
|
||||
# agent/graph/global default when omitted.
|
||||
skill_instructions: null # Per-node skill-hint text override; uses the built-in default when omitted.
|
||||
output_schema: # Optional JSON Schema. The output is parsed to JSON
|
||||
type: object # and its top-level object keys auto-merge into state
|
||||
properties: # (so `topic` / `needs_deep_dive` become {{topic}} etc).
|
||||
|
||||
+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
|
||||
|
||||
+7
-1
@@ -4,7 +4,7 @@ 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::{Parser, crate_authors, crate_description, crate_version};
|
||||
@@ -75,6 +75,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+36
-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;
|
||||
}
|
||||
@@ -464,6 +483,14 @@ impl Agent {
|
||||
self.config.continuation_prompt.clone()
|
||||
}
|
||||
|
||||
pub fn inject_skill_instructions(&self) -> bool {
|
||||
self.config.inject_skill_instructions
|
||||
}
|
||||
|
||||
pub fn skill_instructions_value(&self) -> Option<String> {
|
||||
self.config.skill_instructions.clone()
|
||||
}
|
||||
|
||||
pub fn can_spawn_agents(&self) -> bool {
|
||||
self.config.can_spawn_agents
|
||||
}
|
||||
@@ -625,6 +652,12 @@ pub struct AgentConfig {
|
||||
pub inject_todo_instructions: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub inject_spawn_instructions: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub inject_skill_instructions: bool,
|
||||
#[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)]
|
||||
@@ -704,6 +737,8 @@ impl AgentConfig {
|
||||
mcp_servers: graph.mcp_servers.clone(),
|
||||
skills_enabled: graph.skills_enabled,
|
||||
enabled_skills: graph.enabled_skills.clone(),
|
||||
inject_skill_instructions: graph.inject_skill_instructions.unwrap_or(true),
|
||||
skill_instructions: graph.skill_instructions.clone(),
|
||||
conversation_starters: graph.conversation_starters.clone(),
|
||||
variables: graph.variables.clone(),
|
||||
can_spawn_agents: graph.has_agent_node(),
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct AppConfig {
|
||||
pub max_auto_continues: usize,
|
||||
pub inject_todo_instructions: bool,
|
||||
pub continuation_prompt: Option<String>,
|
||||
pub inject_skill_instructions: bool,
|
||||
pub skill_instructions: Option<String>,
|
||||
|
||||
pub repl_prelude: Option<String>,
|
||||
pub cmd_prelude: Option<String>,
|
||||
@@ -62,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,
|
||||
@@ -118,6 +124,8 @@ impl Default for AppConfig {
|
||||
max_auto_continues: 10,
|
||||
inject_todo_instructions: true,
|
||||
continuation_prompt: None,
|
||||
inject_skill_instructions: true,
|
||||
skill_instructions: None,
|
||||
|
||||
repl_prelude: None,
|
||||
cmd_prelude: None,
|
||||
@@ -128,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,
|
||||
@@ -185,6 +197,8 @@ impl AppConfig {
|
||||
max_auto_continues: config.max_auto_continues,
|
||||
inject_todo_instructions: config.inject_todo_instructions,
|
||||
continuation_prompt: config.continuation_prompt,
|
||||
inject_skill_instructions: config.inject_skill_instructions,
|
||||
skill_instructions: config.skill_instructions,
|
||||
|
||||
repl_prelude: config.repl_prelude,
|
||||
cmd_prelude: config.cmd_prelude,
|
||||
@@ -195,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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+27
-2
@@ -5,8 +5,9 @@ mod input;
|
||||
mod install_remote;
|
||||
mod macros;
|
||||
mod mcp_factory;
|
||||
pub(crate) mod memory;
|
||||
pub(crate) mod paths;
|
||||
mod prompts;
|
||||
pub(crate) mod prompts;
|
||||
mod rag_cache;
|
||||
mod request_context;
|
||||
mod role;
|
||||
@@ -28,7 +29,7 @@ pub use self::app_state::AppState;
|
||||
pub use self::input::Input;
|
||||
pub use self::install_remote::{install_remote, install_remote_from_repl_args};
|
||||
#[allow(unused_imports)]
|
||||
pub use self::request_context::{RenderMode, RequestContext};
|
||||
pub use self::request_context::{RenderMode, RequestContext, should_inject_skill_instructions};
|
||||
pub use self::role::{
|
||||
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
|
||||
};
|
||||
@@ -138,6 +139,12 @@ 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 GIT_DIR_NAME: &str = ".git";
|
||||
const GITIGNORE_FILE_NAME: &str = ".gitignore";
|
||||
const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [
|
||||
"execute_command.sh",
|
||||
"execute_py_code.py",
|
||||
@@ -214,6 +221,8 @@ pub struct Config {
|
||||
pub max_auto_continues: usize,
|
||||
pub inject_todo_instructions: bool,
|
||||
pub continuation_prompt: Option<String>,
|
||||
pub inject_skill_instructions: bool,
|
||||
pub skill_instructions: Option<String>,
|
||||
|
||||
pub repl_prelude: Option<String>,
|
||||
pub cmd_prelude: Option<String>,
|
||||
@@ -224,6 +233,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,
|
||||
@@ -280,6 +293,8 @@ impl Default for Config {
|
||||
max_auto_continues: 10,
|
||||
inject_todo_instructions: true,
|
||||
continuation_prompt: None,
|
||||
inject_skill_instructions: true,
|
||||
skill_instructions: None,
|
||||
|
||||
repl_prelude: None,
|
||||
cmd_prelude: None,
|
||||
@@ -290,6 +305,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,
|
||||
@@ -346,6 +365,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,
|
||||
|
||||
+18
-3
@@ -2,8 +2,9 @@ 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, 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 +14,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")) {
|
||||
@@ -195,6 +196,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()
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
use indoc::indoc;
|
||||
|
||||
pub(crate) const DEFAULT_SKILL_INSTRUCTIONS: &str = indoc! {"
|
||||
## Skills
|
||||
Specialized skills may be available in this context. Call `skill__list` early in a task to
|
||||
discover any that match the work, then `skill__load` the relevant ones. Their instructions and
|
||||
granted tools will become active for subsequent turns. Call `skill__unload` when their work is
|
||||
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:
|
||||
@@ -54,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:
|
||||
|
||||
+558
-10
@@ -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;
|
||||
@@ -39,6 +43,7 @@ use indoc::formatdoc;
|
||||
use inquire::{Confirm, MultiSelect, Text, list_option::ListOption, validator::Validation};
|
||||
use log::warn;
|
||||
use parking_lot::RwLock;
|
||||
use prompts::DEFAULT_SKILL_INSTRUCTIONS;
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::fs::{File, OpenOptions, read_dir, read_to_string, remove_dir_all, remove_file};
|
||||
use std::io::Write;
|
||||
@@ -53,6 +58,35 @@ pub struct AutoContinueConfig {
|
||||
pub continuation_prompt: Option<String>,
|
||||
}
|
||||
|
||||
pub struct SkillInstructionsConfig {
|
||||
pub inject: bool,
|
||||
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
|
||||
/// actually return (cascade-allowed AND surviving `Skill::is_compatible` for current
|
||||
/// `mcp_server_support`), so an empty set means the hint has nothing to point at.
|
||||
pub fn should_inject_skill_instructions(app: &AppConfig, policy: &SkillPolicy) -> bool {
|
||||
app.function_calling_support && policy.skills_enabled && !policy.compatible_enabled.is_empty()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum RenderMode {
|
||||
#[default]
|
||||
@@ -86,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>,
|
||||
@@ -115,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,
|
||||
@@ -170,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,
|
||||
@@ -212,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,
|
||||
@@ -252,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,
|
||||
@@ -634,9 +673,139 @@ impl RequestContext {
|
||||
self.agent.as_ref(),
|
||||
self.session.as_ref(),
|
||||
)?;
|
||||
|
||||
if should_inject_skill_instructions(app, &policy) {
|
||||
let config = self.skill_instructions_config();
|
||||
|
||||
if config.inject {
|
||||
let separator = if role.is_empty_prompt() { "" } else { "\n\n" };
|
||||
|
||||
role.append_to_prompt(separator);
|
||||
role.append_to_prompt(
|
||||
config
|
||||
.instructions
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_SKILL_INSTRUCTIONS),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
pub fn skill_instructions_config(&self) -> SkillInstructionsConfig {
|
||||
if let Some(agent) = &self.agent {
|
||||
return SkillInstructionsConfig {
|
||||
inject: agent.inject_skill_instructions(),
|
||||
instructions: agent.skill_instructions_value(),
|
||||
};
|
||||
}
|
||||
|
||||
let app = &self.app.config;
|
||||
let inject = self
|
||||
.session
|
||||
.as_ref()
|
||||
.and_then(|s| s.inject_skill_instructions())
|
||||
.or_else(|| {
|
||||
self.role
|
||||
.as_ref()
|
||||
.and_then(|r| r.inject_skill_instructions())
|
||||
})
|
||||
.unwrap_or(app.inject_skill_instructions);
|
||||
let instructions = self
|
||||
.session
|
||||
.as_ref()
|
||||
.and_then(|s| s.skill_instructions().map(|v| v.to_string()))
|
||||
.or_else(|| {
|
||||
self.role
|
||||
.as_ref()
|
||||
.and_then(|r| r.skill_instructions().map(|v| v.to_string()))
|
||||
})
|
||||
.or_else(|| app.skill_instructions.clone());
|
||||
|
||||
SkillInstructionsConfig {
|
||||
inject,
|
||||
instructions,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -882,6 +1051,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),
|
||||
@@ -908,6 +1086,7 @@ 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())),
|
||||
@@ -1207,7 +1386,8 @@ impl RequestContext {
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
(v.name.starts_with(USER_FUNCTION_PREFIX)
|
||||
|| v.name.starts_with(SKILL_FUNCTION_PREFIX))
|
||||
|| (!matches!(role.skills_enabled(), Some(false))
|
||||
&& v.name.starts_with(SKILL_FUNCTION_PREFIX)))
|
||||
&& !existing.contains(&v.name)
|
||||
})
|
||||
.cloned()
|
||||
@@ -1707,7 +1887,7 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
let value = match key {
|
||||
"continuation_prompt" => raw_value,
|
||||
"continuation_prompt" | "skill_instructions" => raw_value,
|
||||
_ => {
|
||||
if raw_value.contains(char::is_whitespace) {
|
||||
bail!("Usage: .set <key> <value>. If value is null, unset key.");
|
||||
@@ -1876,11 +2056,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" => {
|
||||
@@ -1907,6 +2091,40 @@ impl RequestContext {
|
||||
self.update_app_config(|app| app.continuation_prompt = value);
|
||||
}
|
||||
}
|
||||
"inject_skill_instructions" => {
|
||||
let value: bool = value.parse().with_context(|| "Invalid value")?;
|
||||
if let Some(session) = self.session.as_mut() {
|
||||
session.set_inject_skill_instructions(Some(value));
|
||||
} else {
|
||||
self.update_app_config(|app| app.inject_skill_instructions = value);
|
||||
}
|
||||
}
|
||||
"skill_instructions" => {
|
||||
let value: Option<String> = super::parse_value(value)?;
|
||||
if let Some(session) = self.session.as_mut() {
|
||||
session.set_skill_instructions(value);
|
||||
} else {
|
||||
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(())
|
||||
@@ -2006,6 +2224,8 @@ impl RequestContext {
|
||||
"enabled_tools",
|
||||
"enabled_mcp_servers",
|
||||
"inject_todo_instructions",
|
||||
"inject_skill_instructions",
|
||||
"skill_instructions",
|
||||
"max_auto_continues",
|
||||
"save_session",
|
||||
"compression_threshold",
|
||||
@@ -2172,6 +2392,12 @@ impl RequestContext {
|
||||
super::complete_bool(config.inject_instructions)
|
||||
}
|
||||
"continuation_prompt" => vec!["null".to_string()],
|
||||
"inject_skill_instructions" => {
|
||||
let config = self.skill_instructions_config();
|
||||
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();
|
||||
@@ -2304,6 +2530,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 {
|
||||
@@ -2563,7 +2792,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;
|
||||
@@ -2572,6 +2801,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();
|
||||
@@ -2981,11 +3211,12 @@ mod tests {
|
||||
use super::super::mcp_factory::McpFactory;
|
||||
use super::*;
|
||||
use crate::config::AppState;
|
||||
use crate::function::ToolCall;
|
||||
use crate::function::{ToolCall, skill};
|
||||
use crate::mcp::{McpServer, McpServersConfig, McpTransportType};
|
||||
use crate::utils;
|
||||
use crate::utils::get_env_name;
|
||||
use crate::vault::Vault;
|
||||
use serde_json::json;
|
||||
use serial_test::serial;
|
||||
use std::env;
|
||||
use std::fs::{create_dir_all, remove_dir_all, write};
|
||||
@@ -3070,6 +3301,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();
|
||||
@@ -3123,6 +3394,108 @@ mod tests {
|
||||
assert_eq!(extracted.name(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_inject_skill_instructions_requires_function_calling() {
|
||||
let app = AppConfig {
|
||||
function_calling_support: false,
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
let policy = SkillPolicy {
|
||||
skills_enabled: true,
|
||||
enabled: ["a".to_string()].into_iter().collect(),
|
||||
compatible_enabled: ["a".to_string()].into_iter().collect(),
|
||||
};
|
||||
|
||||
assert!(!should_inject_skill_instructions(&app, &policy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_inject_skill_instructions_requires_skills_enabled() {
|
||||
let app = AppConfig {
|
||||
function_calling_support: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
let policy = SkillPolicy {
|
||||
skills_enabled: false,
|
||||
enabled: ["a".to_string()].into_iter().collect(),
|
||||
compatible_enabled: ["a".to_string()].into_iter().collect(),
|
||||
};
|
||||
|
||||
assert!(!should_inject_skill_instructions(&app, &policy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_inject_skill_instructions_suppresses_when_no_compatible_skills() {
|
||||
let app = AppConfig {
|
||||
function_calling_support: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
// `enabled` has names, but none survive the compatibility filter — hint must suppress.
|
||||
let policy = SkillPolicy {
|
||||
skills_enabled: true,
|
||||
enabled: ["a".to_string()].into_iter().collect(),
|
||||
compatible_enabled: Default::default(),
|
||||
};
|
||||
|
||||
assert!(!should_inject_skill_instructions(&app, &policy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_inject_skill_instructions_when_all_conditions_met() {
|
||||
let app = AppConfig {
|
||||
function_calling_support: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
let policy = SkillPolicy {
|
||||
skills_enabled: true,
|
||||
enabled: ["a".to_string()].into_iter().collect(),
|
||||
compatible_enabled: ["a".to_string()].into_iter().collect(),
|
||||
};
|
||||
|
||||
assert!(should_inject_skill_instructions(&app, &policy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_instructions_config_falls_back_to_app_default() {
|
||||
let ctx = create_test_ctx();
|
||||
|
||||
let cfg = ctx.skill_instructions_config();
|
||||
|
||||
assert!(cfg.inject);
|
||||
assert!(cfg.instructions.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_instructions_config_respects_role_disable() {
|
||||
let mut ctx = create_test_ctx();
|
||||
let role = Role::new("r", "---\ninject_skill_instructions: false\n---\nhello");
|
||||
ctx.use_role_obj(role).unwrap();
|
||||
|
||||
let cfg = ctx.skill_instructions_config();
|
||||
|
||||
assert!(!cfg.inject);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_instructions_config_session_overrides_role() {
|
||||
let mut ctx = create_test_ctx();
|
||||
let role = Role::new("r", "---\ninject_skill_instructions: false\n---\nhello");
|
||||
ctx.use_role_obj(role).unwrap();
|
||||
let mut session = Session::default();
|
||||
session.set_inject_skill_instructions(Some(true));
|
||||
session.set_skill_instructions(Some("custom hint".into()));
|
||||
ctx.session = Some(session);
|
||||
|
||||
let cfg = ctx.skill_instructions_config();
|
||||
|
||||
assert!(cfg.inject);
|
||||
assert_eq!(cfg.instructions.as_deref(), Some("custom hint"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_session_clears_session() {
|
||||
let mut ctx = create_test_ctx();
|
||||
@@ -3448,6 +3821,182 @@ mod tests {
|
||||
assert!(!names.contains(&"todo__done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_functions_re_adds_skill_tools_when_role_skills_enabled_unset() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.tool_scope.functions.append_skill_functions();
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec!["foo".to_string()]));
|
||||
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"skill__list"));
|
||||
assert!(names.contains(&"skill__load"));
|
||||
assert!(names.contains(&"skill__unload"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_functions_suppresses_skill_tools_when_role_skills_enabled_false() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.tool_scope.functions.append_skill_functions();
|
||||
ctx.tool_scope.functions.append_todo_functions();
|
||||
|
||||
let mut role = Role::new("r", "---\nskills_enabled: false\n---\np");
|
||||
role.set_enabled_tools(Some(vec!["todo__init".to_string()]));
|
||||
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"todo__init"));
|
||||
assert!(!names.contains(&"skill__list"));
|
||||
assert!(!names.contains(&"skill__load"));
|
||||
assert!(!names.contains(&"skill__unload"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_functions_still_re_adds_user_tools_when_role_skills_enabled_false() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.tool_scope.functions.append_user_interaction_functions();
|
||||
ctx.tool_scope.functions.append_skill_functions();
|
||||
|
||||
let mut role = Role::new("r", "---\nskills_enabled: false\n---\np");
|
||||
role.set_enabled_tools(Some(vec!["foo".to_string()]));
|
||||
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"user__ask"));
|
||||
assert!(!names.contains(&"skill__list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn select_functions_re_adds_skill_tools_when_agent_skills_enabled_not_false() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let mut ctx = create_test_ctx();
|
||||
let app = ctx.app.config.clone();
|
||||
let agent_name = format!(
|
||||
"test_skill_agent_{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let agent_dir = paths::agent_data_dir(&agent_name);
|
||||
create_dir_all(&agent_dir).unwrap();
|
||||
write(
|
||||
agent_dir.join("graph.yaml"),
|
||||
format!(
|
||||
"name: {agent_name}\nversion: \"1.0\"\nstart: done\nnodes:\n done:\n type: end\n output: ok\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let abort = utils::create_abort_signal();
|
||||
run_async(ctx.use_agent(&app, &agent_name, None, abort)).unwrap();
|
||||
ctx.tool_scope.functions.append_skill_functions();
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec!["foo".to_string()]));
|
||||
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"skill__list"));
|
||||
assert!(names.contains(&"skill__load"));
|
||||
assert!(names.contains(&"skill__unload"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_for_branch_clones_skill_registry() {
|
||||
let mut ctx = create_test_ctx();
|
||||
let skill = Skill::new("shared", "---\nauto_unload: false\n---\nbody");
|
||||
ctx.skill_registry.insert(skill).unwrap();
|
||||
|
||||
let fork = ctx.fork_for_branch();
|
||||
|
||||
assert!(
|
||||
fork.skill_registry.is_loaded("shared"),
|
||||
"Parallel branches must share loaded skills with parent"
|
||||
);
|
||||
assert!(ctx.skill_registry.is_loaded("shared"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_skill_tool_returns_error_when_skills_disabled() {
|
||||
let mut ctx = create_test_ctx();
|
||||
let role = Role::new("r", "---\nskills_enabled: false\n---\np");
|
||||
ctx.use_role_obj(role).unwrap();
|
||||
|
||||
let result = run_async(skill::handle_skill_tool(
|
||||
&mut ctx,
|
||||
"skill__list",
|
||||
&json!({}),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("error").is_some(),
|
||||
"Expected error when skills are disabled, got: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_unload_returns_error_when_skill_not_loaded() {
|
||||
let mut ctx = create_test_ctx();
|
||||
|
||||
let result = run_async(skill::handle_skill_tool(
|
||||
&mut ctx,
|
||||
"skill__unload",
|
||||
&json!({"name": "ghost"}),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("error").is_some(),
|
||||
"Expected error when unloading unloaded skill, got: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn select_functions_suppresses_skill_tools_when_agent_skills_enabled_false() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let mut ctx = create_test_ctx();
|
||||
let app = ctx.app.config.clone();
|
||||
let agent_name = format!(
|
||||
"test_skill_agent_off_{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let agent_dir = paths::agent_data_dir(&agent_name);
|
||||
create_dir_all(&agent_dir).unwrap();
|
||||
write(
|
||||
agent_dir.join("graph.yaml"),
|
||||
format!(
|
||||
"name: {agent_name}\nversion: \"1.0\"\nstart: done\nnodes:\n done:\n type: end\n output: ok\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let abort = utils::create_abort_signal();
|
||||
run_async(ctx.use_agent(&app, &agent_name, None, abort)).unwrap();
|
||||
ctx.agent
|
||||
.as_mut()
|
||||
.expect("agent loaded")
|
||||
.set_skills_enabled(Some(false));
|
||||
ctx.tool_scope.functions.append_skill_functions();
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec!["foo".to_string()]));
|
||||
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(!names.contains(&"skill__list"));
|
||||
assert!(!names.contains(&"skill__load"));
|
||||
assert!(!names.contains(&"skill__unload"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_enabled_mcp_servers_returns_empty_when_mcp_disabled() {
|
||||
let app_state = {
|
||||
@@ -3677,8 +4226,7 @@ mod tests {
|
||||
|
||||
let input = Input::from_str(&ctx, "hello", None).unwrap();
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
let tool_result =
|
||||
ToolResult::new(crate::function::ToolCall::default(), serde_json::json!({}));
|
||||
let tool_result = ToolResult::new(crate::function::ToolCall::default(), json!({}));
|
||||
ctx.after_chat_completion(app.as_ref(), &input, "", &[tool_result])
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ pub struct Role {
|
||||
inject_todo_instructions: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
continuation_prompt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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,
|
||||
@@ -124,6 +130,11 @@ impl Role {
|
||||
"continuation_prompt" => {
|
||||
role.continuation_prompt = value.as_str().map(|v| v.to_string())
|
||||
}
|
||||
"inject_skill_instructions" => role.inject_skill_instructions = value.as_bool(),
|
||||
"skill_instructions" => {
|
||||
role.skill_instructions = value.as_str().map(|v| v.to_string())
|
||||
}
|
||||
"memory" => role.memory = value.as_bool(),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
@@ -189,6 +200,17 @@ impl Role {
|
||||
if let Some(continuation_prompt) = &self.continuation_prompt {
|
||||
metadata.push(format!("continuation_prompt: {continuation_prompt}"));
|
||||
}
|
||||
if let Some(inject_skill_instructions) = self.inject_skill_instructions {
|
||||
metadata.push(format!(
|
||||
"inject_skill_instructions: {inject_skill_instructions}"
|
||||
));
|
||||
}
|
||||
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() {
|
||||
@@ -299,6 +321,18 @@ impl Role {
|
||||
self.continuation_prompt.as_deref()
|
||||
}
|
||||
|
||||
pub fn inject_skill_instructions(&self) -> Option<bool> {
|
||||
self.inject_skill_instructions
|
||||
}
|
||||
|
||||
pub fn skill_instructions(&self) -> Option<&str> {
|
||||
self.skill_instructions.as_deref()
|
||||
}
|
||||
|
||||
pub fn memory(&self) -> Option<bool> {
|
||||
self.memory
|
||||
}
|
||||
|
||||
pub fn skills_enabled(&self) -> Option<bool> {
|
||||
self.skills_enabled
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ pub struct Session {
|
||||
inject_todo_instructions: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
continuation_prompt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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>,
|
||||
@@ -227,6 +233,15 @@ impl Session {
|
||||
if let Some(continuation_prompt) = self.continuation_prompt() {
|
||||
data["continuation_prompt"] = continuation_prompt.into();
|
||||
}
|
||||
if let Some(inject_skill_instructions) = self.inject_skill_instructions() {
|
||||
data["inject_skill_instructions"] = inject_skill_instructions.into();
|
||||
}
|
||||
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() {
|
||||
@@ -305,6 +320,18 @@ impl Session {
|
||||
if let Some(continuation_prompt) = self.continuation_prompt() {
|
||||
items.push(("continuation_prompt", continuation_prompt.to_string()));
|
||||
}
|
||||
if let Some(inject_skill_instructions) = self.inject_skill_instructions() {
|
||||
items.push((
|
||||
"inject_skill_instructions",
|
||||
inject_skill_instructions.to_string(),
|
||||
));
|
||||
}
|
||||
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()));
|
||||
@@ -446,6 +473,18 @@ impl Session {
|
||||
self.continuation_prompt.as_deref()
|
||||
}
|
||||
|
||||
pub fn inject_skill_instructions(&self) -> Option<bool> {
|
||||
self.inject_skill_instructions
|
||||
}
|
||||
|
||||
pub fn skill_instructions(&self) -> Option<&str> {
|
||||
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;
|
||||
@@ -460,6 +499,27 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_inject_skill_instructions(&mut self, value: Option<bool>) {
|
||||
if self.inject_skill_instructions != value {
|
||||
self.inject_skill_instructions = value;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn needs_compression(&self, global_compression_threshold: usize) -> bool {
|
||||
if self.compressing {
|
||||
return false;
|
||||
|
||||
+251
-29
@@ -3,14 +3,16 @@ use super::app_config::AppConfig;
|
||||
use super::paths;
|
||||
use super::role::Role;
|
||||
use super::session::Session;
|
||||
use super::skill::Skill;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SkillPolicy {
|
||||
pub skills_enabled: bool,
|
||||
pub enabled: HashSet<String>,
|
||||
pub compatible_enabled: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl SkillPolicy {
|
||||
@@ -27,20 +29,27 @@ impl SkillPolicy {
|
||||
session,
|
||||
&paths::has_skill,
|
||||
&paths::list_skills,
|
||||
&|name, mcp_on| {
|
||||
Skill::load(name)
|
||||
.map(|s| s.is_compatible(mcp_on))
|
||||
.unwrap_or(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn effective_with<F, G>(
|
||||
fn effective_with<F, G, H>(
|
||||
global: &AppConfig,
|
||||
role: Option<&Role>,
|
||||
agent: Option<&Agent>,
|
||||
session: Option<&Session>,
|
||||
skill_exists: &F,
|
||||
list_installed: &G,
|
||||
skill_is_compatible: &H,
|
||||
) -> Result<Self>
|
||||
where
|
||||
F: Fn(&str) -> bool,
|
||||
G: Fn() -> Vec<String>,
|
||||
H: Fn(&str, bool) -> bool,
|
||||
{
|
||||
let mut skills_enabled = global.skills_enabled;
|
||||
if let Some(r) = role
|
||||
@@ -104,9 +113,21 @@ impl SkillPolicy {
|
||||
},
|
||||
};
|
||||
|
||||
let compatible_enabled: BTreeSet<String> = if skills_enabled {
|
||||
let mcp_on = global.mcp_server_support;
|
||||
enabled
|
||||
.iter()
|
||||
.filter(|name| skill_is_compatible(name, mcp_on))
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
skills_enabled,
|
||||
enabled,
|
||||
compatible_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,6 +149,10 @@ mod tests {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn all_compatible(_: &str, _: bool) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn make_app_config(
|
||||
skills_enabled: bool,
|
||||
enabled: Option<&str>,
|
||||
@@ -145,9 +170,16 @@ mod tests {
|
||||
fn defaults_yield_skills_enabled_with_empty_universe() {
|
||||
let global = AppConfig::default();
|
||||
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap();
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.skills_enabled);
|
||||
assert!(policy.enabled.is_empty());
|
||||
@@ -158,9 +190,16 @@ mod tests {
|
||||
let global = AppConfig::default();
|
||||
let installed = || vec!["alpha".to_string(), "beta".to_string()];
|
||||
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &installed)
|
||||
.unwrap();
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(policy.enabled.len(), 2);
|
||||
assert!(policy.enabled.contains("alpha"));
|
||||
@@ -171,9 +210,16 @@ mod tests {
|
||||
fn falls_back_to_visible_when_visible_set_but_no_enabled() {
|
||||
let global = make_app_config(true, None, Some(&["alpha", "beta"]));
|
||||
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap();
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(policy.enabled.len(), 2);
|
||||
assert!(policy.enabled.contains("alpha"));
|
||||
@@ -184,9 +230,16 @@ mod tests {
|
||||
fn global_enabled_skills_is_effective_when_no_other_levels() {
|
||||
let global = make_app_config(true, Some("alpha,beta"), Some(&["alpha", "beta", "gamma"]));
|
||||
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap();
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.enabled.contains("alpha"));
|
||||
assert!(policy.enabled.contains("beta"));
|
||||
@@ -205,6 +258,7 @@ mod tests {
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -224,6 +278,7 @@ mod tests {
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -237,9 +292,15 @@ mod tests {
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
let policy = SkillPolicy::effective_with(&global, None, None, None, &always_true, &|| {
|
||||
vec!["alpha".to_string()]
|
||||
})
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&|| vec!["alpha".to_string()],
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!policy.allows("alpha"));
|
||||
@@ -249,9 +310,16 @@ mod tests {
|
||||
fn allows_returns_true_when_skill_in_enabled_set() {
|
||||
let global = make_app_config(true, Some("alpha"), None);
|
||||
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap();
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.allows("alpha"));
|
||||
assert!(!policy.allows("beta"));
|
||||
@@ -261,9 +329,16 @@ mod tests {
|
||||
fn validation_rejects_uninstalled_skill_reference() {
|
||||
let global = make_app_config(true, Some("ghost"), None);
|
||||
|
||||
let err =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &|_| false, &empty_installed)
|
||||
.unwrap_err();
|
||||
let err = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&|_| false,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("not installed"));
|
||||
assert!(err.to_string().contains("ghost"));
|
||||
@@ -273,9 +348,16 @@ mod tests {
|
||||
fn validation_rejects_skill_not_in_visible_set() {
|
||||
let global = make_app_config(true, Some("beta"), Some(&["alpha"]));
|
||||
|
||||
let err =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap_err();
|
||||
let err = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
@@ -288,9 +370,16 @@ mod tests {
|
||||
fn validation_skipped_when_no_explicit_enabled_skills() {
|
||||
let global = make_app_config(true, None, None);
|
||||
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &|_| false, &empty_installed)
|
||||
.unwrap();
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&|_| false,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.enabled.is_empty());
|
||||
}
|
||||
@@ -307,9 +396,142 @@ mod tests {
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.enabled.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_enabled_is_empty_when_skills_disabled() {
|
||||
let global = AppConfig {
|
||||
skills_enabled: false,
|
||||
enabled_skills: Some(vec!["alpha".into()]),
|
||||
visible_skills: Some(vec!["alpha".into()]),
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!policy.skills_enabled);
|
||||
assert!(policy.compatible_enabled.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_enabled_short_circuits_callback_when_skills_disabled() {
|
||||
use std::cell::Cell;
|
||||
let global = AppConfig {
|
||||
skills_enabled: false,
|
||||
enabled_skills: Some(vec!["alpha".into()]),
|
||||
visible_skills: Some(vec!["alpha".into()]),
|
||||
..AppConfig::default()
|
||||
};
|
||||
let invoked = Cell::new(0u32);
|
||||
let counting = |_: &str, _: bool| {
|
||||
invoked.set(invoked.get() + 1);
|
||||
true
|
||||
};
|
||||
|
||||
SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&counting,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
invoked.get(),
|
||||
0,
|
||||
"skill_is_compatible callback must not run when skills are disabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_enabled_includes_all_when_callback_passes() {
|
||||
let global = make_app_config(true, Some("alpha,beta"), Some(&["alpha", "beta"]));
|
||||
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(policy.compatible_enabled.len(), 2);
|
||||
assert!(policy.compatible_enabled.contains("alpha"));
|
||||
assert!(policy.compatible_enabled.contains("beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_enabled_excludes_incompatible_skills() {
|
||||
let global = make_app_config(true, Some("alpha,beta"), Some(&["alpha", "beta"]));
|
||||
let only_alpha_compat = |name: &str, _: bool| name == "alpha";
|
||||
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&only_alpha_compat,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.compatible_enabled.contains("alpha"));
|
||||
assert!(!policy.compatible_enabled.contains("beta"));
|
||||
assert_eq!(policy.compatible_enabled.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_enabled_passes_mcp_flag_to_callback() {
|
||||
use std::cell::Cell;
|
||||
let global = AppConfig {
|
||||
skills_enabled: true,
|
||||
mcp_server_support: false,
|
||||
enabled_skills: Some(vec!["alpha".into()]),
|
||||
visible_skills: Some(vec!["alpha".into()]),
|
||||
..AppConfig::default()
|
||||
};
|
||||
let observed_mcp = Cell::new(None::<bool>);
|
||||
let capture = |_: &str, mcp_on: bool| {
|
||||
observed_mcp.set(Some(mcp_on));
|
||||
true
|
||||
};
|
||||
|
||||
SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&capture,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
observed_mcp.get(),
|
||||
Some(false),
|
||||
"callback must receive mcp_server_support flag from AppConfig"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ impl SkillRegistry {
|
||||
let policy = SkillPolicy {
|
||||
skills_enabled: true,
|
||||
enabled: self.loaded.keys().cloned().collect(),
|
||||
compatible_enabled: self.loaded.keys().cloned().collect(),
|
||||
};
|
||||
self.effective_role(base, &policy)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+10
-15
@@ -14,9 +14,11 @@ pub fn skill_function_declarations() -> Vec<FunctionDeclaration> {
|
||||
FunctionDeclaration {
|
||||
name: format!("{SKILL_FUNCTION_PREFIX}list"),
|
||||
description:
|
||||
"List skills available in this context. Returns each skill's name, description, \
|
||||
what tools and MCP servers it grants on load, and whether it is currently loaded. \
|
||||
Call this to discover skills before using skill__load."
|
||||
"List skills available in this context. Call this early in any non-trivial task to \
|
||||
discover specialized skills that may apply to the work before deciding on an \
|
||||
approach. Returns each skill's name, description, what tools and MCP servers it \
|
||||
grants on load, and whether it is currently loaded. Pair with `skill__load` to \
|
||||
activate the skills you choose."
|
||||
.to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
@@ -28,9 +30,10 @@ pub fn skill_function_declarations() -> Vec<FunctionDeclaration> {
|
||||
FunctionDeclaration {
|
||||
name: format!("{SKILL_FUNCTION_PREFIX}load"),
|
||||
description:
|
||||
"Load a skill module into the current context. The skill's instructions and any \
|
||||
tools or MCP servers it grants become active for subsequent turns. Call \
|
||||
skill__unload when the skill's work is complete to keep the context lean."
|
||||
"Load a skill module into the current context after confirming via `skill__list` \
|
||||
that it applies to the task at hand. The skill's instructions and any tools or \
|
||||
MCP servers it grants become active for subsequent turns. Call `skill__unload` \
|
||||
when the skill's work is complete to keep the context lean."
|
||||
.to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
@@ -102,8 +105,6 @@ pub async fn handle_skill_tool(
|
||||
}
|
||||
|
||||
fn handle_list(ctx: &RequestContext, policy: &SkillPolicy) -> Result<Value> {
|
||||
let mcp_on = ctx.app.config.mcp_server_support;
|
||||
|
||||
let visible_names: Vec<String> = match ctx.app.config.visible_skills.as_deref() {
|
||||
Some(list) => list.to_vec(),
|
||||
None => paths::list_skills(),
|
||||
@@ -111,7 +112,7 @@ fn handle_list(ctx: &RequestContext, policy: &SkillPolicy) -> Result<Value> {
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for name in visible_names {
|
||||
if !policy.allows(&name) {
|
||||
if !policy.compatible_enabled.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -122,12 +123,6 @@ fn handle_list(ctx: &RequestContext, policy: &SkillPolicy) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !skill.is_compatible(mcp_on) {
|
||||
warn!(
|
||||
"Skill '{name}' filtered from list: declares MCP servers but MCP support is disabled"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push(json!({
|
||||
"name": skill.name(),
|
||||
|
||||
+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());
|
||||
}
|
||||
|
||||
|
||||
+55
-2
@@ -2,10 +2,15 @@ use super::state::StateManager;
|
||||
use super::structured;
|
||||
use super::types::LlmNode;
|
||||
use crate::client::{Model, ModelType, call_chat_completions};
|
||||
use crate::config::{Input, RequestContext, Role, RoleLike, SkillPolicy};
|
||||
use crate::config::prompts::DEFAULT_SKILL_INSTRUCTIONS;
|
||||
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;
|
||||
@@ -139,6 +144,31 @@ async fn run(
|
||||
role.set_enabled_tools(Some(tools));
|
||||
}
|
||||
|
||||
if should_inject_skill_instructions(&parent_ctx.app.config, &policy) {
|
||||
let app = &parent_ctx.app.config;
|
||||
let agent = parent_ctx.agent.as_ref();
|
||||
let inject = node
|
||||
.inject_skill_instructions
|
||||
.or_else(|| agent.map(|a| a.inject_skill_instructions()))
|
||||
.unwrap_or(app.inject_skill_instructions);
|
||||
|
||||
if inject {
|
||||
let instructions = node
|
||||
.skill_instructions
|
||||
.clone()
|
||||
.or_else(|| agent.and_then(|a| a.skill_instructions_value()))
|
||||
.or_else(|| app.skill_instructions.clone());
|
||||
let separator = if role.is_empty_prompt() { "" } else { "\n\n" };
|
||||
|
||||
role.append_to_prompt(separator);
|
||||
role.append_to_prompt(
|
||||
instructions
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_SKILL_INSTRUCTIONS),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let composed_role = parent_ctx.skill_registry.effective_role(&role, &policy);
|
||||
|
||||
let saved_role = parent_ctx.role.clone();
|
||||
@@ -238,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 {
|
||||
@@ -456,6 +507,8 @@ mod tests {
|
||||
timeout: None,
|
||||
skills_enabled: None,
|
||||
enabled_skills: None,
|
||||
inject_skill_instructions: None,
|
||||
skill_instructions: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ pub struct Graph {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_skills: Option<Vec<String>>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub inject_skill_instructions: Option<bool>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skill_instructions: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub conversation_starters: Vec<String>,
|
||||
|
||||
@@ -305,6 +311,12 @@ pub struct LlmNode {
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_skills: Option<Vec<String>>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub inject_skill_instructions: Option<bool>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skill_instructions: Option<String>,
|
||||
}
|
||||
|
||||
fn default_llm_max_attempts() -> u32 {
|
||||
|
||||
@@ -950,6 +950,8 @@ mod tests {
|
||||
mcp_servers: Vec::new(),
|
||||
skills_enabled: None,
|
||||
enabled_skills: None,
|
||||
inject_skill_instructions: None,
|
||||
skill_instructions: None,
|
||||
conversation_starters: Vec::new(),
|
||||
variables: Vec::new(),
|
||||
settings: GraphSettings::default(),
|
||||
@@ -1051,6 +1053,8 @@ mod tests {
|
||||
timeout: None,
|
||||
skills_enabled: None,
|
||||
enabled_skills: None,
|
||||
inject_skill_instructions: None,
|
||||
skill_instructions: None,
|
||||
}),
|
||||
next: next.map(NextTargets::from),
|
||||
}
|
||||
|
||||
+49
-5
@@ -22,10 +22,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 +36,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<()> {
|
||||
@@ -292,12 +293,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 +420,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()
|
||||
|
||||
@@ -12,6 +12,7 @@ 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,
|
||||
@@ -306,6 +307,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 +319,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 +444,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)
|
||||
{
|
||||
@@ -1011,6 +1021,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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user