9 Commits

Author SHA1 Message Date
Dark-Alex-17 bca25404ab docs: migrated location of skill_instructions examples in example configs
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-06-05 15:34:25 -06:00
github-actions[bot] 161fa2d983 chore: bump Cargo.toml to 0.6.0 2026-06-05 21:30:01 +00:00
github-actions[bot] 0e93775491 bump: version 0.5.0 → 0.6.0 [skip ci] 2026-06-05 21:29:49 +00:00
Dark-Alex-17 c00c4ff84a test: added a few additional tests to the request_context surrounding the skills system 2026-06-05 15:24:51 -06:00
Dark-Alex-17 46685cb641 fix: disable skills for specific built-in roles 2026-06-05 15:11:22 -06:00
Dark-Alex-17 165d0d113d feat: added skill hint prompt injection and configuration 2026-06-05 14:48:54 -06:00
Dark-Alex-17 70dc7c9680 fix: redirect stderr into user's /dev/tty for guards 2026-06-05 12:46:52 -06:00
Dark-Alex-17 4eac536327 docs: updated the graph.example.yaml 2026-06-05 11:53:19 -06:00
Alex Clarke 8e0fa79ff3 Merge pull request #13 from Dark-Alex-17/skills
Implement support for skills and refactored secrets providers
2026-06-05 11:43:15 -06:00
25 changed files with 1004 additions and 164 deletions
+81
View File
@@ -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) ## v0.5.0 (2026-05-27)
### Feat ### Feat
Generated
+92 -98
View File
@@ -278,9 +278,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "aws-config" name = "aws-config"
version = "1.8.17" version = "1.8.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "517aa062d8bd9015ee23d6daa5e1c1372328412fdae4e6c4c1be9b69c6ad37a2" checksum = "e33f815b73a3899c03b380d543532e5865f230dce9678d108dc10732a8682275"
dependencies = [ dependencies = [
"aws-credential-types", "aws-credential-types",
"aws-runtime", "aws-runtime",
@@ -381,10 +381,11 @@ dependencies = [
[[package]] [[package]]
name = "aws-sdk-secretsmanager" name = "aws-sdk-secretsmanager"
version = "1.105.0" version = "1.107.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c4e56ac810211dc33810c7aa3612eda29a8b1e8c7e2db6e960c8657e3d95e42" checksum = "63da8ec2dca98a68d8bcba971abae5f06e2c9c0017f43097d1ff92cff96adc54"
dependencies = [ dependencies = [
"arc-swap",
"aws-credential-types", "aws-credential-types",
"aws-runtime", "aws-runtime",
"aws-smithy-async", "aws-smithy-async",
@@ -405,10 +406,11 @@ dependencies = [
[[package]] [[package]]
name = "aws-sdk-sso" name = "aws-sdk-sso"
version = "1.99.0" version = "1.101.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f4055e6099b2ec264abdc0d9bbfffce306c1601809275c861594779a0b04b45" checksum = "b647baea49ff551960b904f905681e9b4765a6c4ea08631e89dc52d8bd3f5896"
dependencies = [ dependencies = [
"arc-swap",
"aws-credential-types", "aws-credential-types",
"aws-runtime", "aws-runtime",
"aws-smithy-async", "aws-smithy-async",
@@ -429,10 +431,11 @@ dependencies = [
[[package]] [[package]]
name = "aws-sdk-ssooidc" name = "aws-sdk-ssooidc"
version = "1.101.0" version = "1.103.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02f009ba0284c5d696425fd7b4dcc5b189f5726f4041b7a5794daecb3a68d598" checksum = "7ae401c65ff288aa7873117fe535cd32b7b1bb0bc43751d28901a1d5f20636b9"
dependencies = [ dependencies = [
"arc-swap",
"aws-credential-types", "aws-credential-types",
"aws-runtime", "aws-runtime",
"aws-smithy-async", "aws-smithy-async",
@@ -453,10 +456,11 @@ dependencies = [
[[package]] [[package]]
name = "aws-sdk-sts" name = "aws-sdk-sts"
version = "1.104.0" version = "1.106.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aa6622798e19e6a76b690562085dd4771c736cd48343464a53ab4ae2f2c9f84" checksum = "4c80de7bb7d03e9ca8c9fd7b489f20f3948d3f3be91a7953591347d238115408"
dependencies = [ dependencies = [
"arc-swap",
"aws-credential-types", "aws-credential-types",
"aws-runtime", "aws-runtime",
"aws-smithy-async", "aws-smithy-async",
@@ -478,9 +482,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-sigv4" name = "aws-sigv4"
version = "1.4.4" version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7083fb918b38474ac65ffbf8a69fc8792d36879f4ac5f1667b43aec61efe9a5" checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71"
dependencies = [ dependencies = [
"aws-credential-types", "aws-credential-types",
"aws-smithy-http", "aws-smithy-http",
@@ -543,9 +547,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-smithy-http-client" name = "aws-smithy-http-client"
version = "1.1.12" version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3"
dependencies = [ dependencies = [
"aws-smithy-async", "aws-smithy-async",
"aws-smithy-runtime-api", "aws-smithy-runtime-api",
@@ -556,7 +560,7 @@ dependencies = [
"http 1.4.1", "http 1.4.1",
"http-body 0.4.6", "http-body 0.4.6",
"hyper 0.14.32", "hyper 0.14.32",
"hyper 1.10.0", "hyper 1.10.1",
"hyper-rustls 0.24.2", "hyper-rustls 0.24.2",
"hyper-rustls 0.27.9", "hyper-rustls 0.27.9",
"hyper-util", "hyper-util",
@@ -573,9 +577,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-smithy-json" name = "aws-smithy-json"
version = "0.62.6" version = "0.62.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "517089205f18ab4adc5a3e02888cb139bbbbb2e168eac9f396216925d1fbeaf5" checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa"
dependencies = [ dependencies = [
"aws-smithy-runtime-api", "aws-smithy-runtime-api",
"aws-smithy-schema", "aws-smithy-schema",
@@ -629,9 +633,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-smithy-runtime-api" name = "aws-smithy-runtime-api"
version = "1.12.1" version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc117c179ecf39a62a0a3f49f600e9ac26a7ad7dd172177999f83933af776c32" checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c"
dependencies = [ dependencies = [
"aws-smithy-async", "aws-smithy-async",
"aws-smithy-runtime-api-macros", "aws-smithy-runtime-api-macros",
@@ -669,9 +673,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-smithy-types" name = "aws-smithy-types"
version = "1.4.8" version = "1.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "056b66dbce2f81cc0c1e2b05bb402eb58f8a3530479d650efadd5bbae9a4050b" checksum = "53f93074121a1be41317b9aa607143ae17900631f7f59a99f2b905d519d6783b"
dependencies = [ dependencies = [
"base64-simd", "base64-simd",
"bytes", "bytes",
@@ -899,7 +903,7 @@ dependencies = [
"quote", "quote",
"regex", "regex",
"rustc-hash", "rustc-hash",
"shlex", "shlex 1.3.0",
"syn", "syn",
] ]
@@ -920,9 +924,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
dependencies = [ dependencies = [
"serde_core", "serde_core",
] ]
@@ -1056,14 +1060,14 @@ checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.62" version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
"libc", "libc",
"shlex", "shlex 2.0.1",
] ]
[[package]] [[package]]
@@ -1124,9 +1128,9 @@ dependencies = [
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
@@ -1190,7 +1194,7 @@ dependencies = [
"clap", "clap",
"clap_lex", "clap_lex",
"is_executable", "is_executable",
"shlex", "shlex 1.3.0",
] ]
[[package]] [[package]]
@@ -1241,9 +1245,9 @@ dependencies = [
[[package]] [[package]]
name = "cmov" name = "cmov"
version = "0.5.3" version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
@@ -1398,7 +1402,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "coyote-ai" name = "coyote-ai"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"ansi_colours", "ansi_colours",
"anyhow", "anyhow",
@@ -2333,7 +2337,7 @@ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
"futures", "futures",
"hyper 1.10.0", "hyper 1.10.1",
"jsonwebtoken", "jsonwebtoken",
"once_cell", "once_cell",
"prost", "prost",
@@ -2766,9 +2770,9 @@ dependencies = [
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.10.0" version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -2808,7 +2812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [ dependencies = [
"http 1.4.1", "http 1.4.1",
"hyper 1.10.0", "hyper 1.10.1",
"hyper-util", "hyper-util",
"rustls 0.23.40", "rustls 0.23.40",
"rustls-native-certs", "rustls-native-certs",
@@ -2823,7 +2827,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [ dependencies = [
"hyper 1.10.0", "hyper 1.10.1",
"hyper-util", "hyper-util",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
@@ -2838,7 +2842,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [ dependencies = [
"bytes", "bytes",
"http-body-util", "http-body-util",
"hyper 1.10.0", "hyper 1.10.1",
"hyper-util", "hyper-util",
"native-tls", "native-tls",
"tokio", "tokio",
@@ -2858,12 +2862,12 @@ dependencies = [
"futures-util", "futures-util",
"http 1.4.1", "http 1.4.1",
"http-body 1.0.1", "http-body 1.0.1",
"hyper 1.10.0", "hyper 1.10.1",
"ipnet", "ipnet",
"libc", "libc",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2 0.6.3", "socket2 0.6.4",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -3153,9 +3157,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "jiff" name = "jiff"
version = "0.2.27" version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "392c70591e8749fe235ddaf513e6f58b26bce3dcc16524cecc8936f75afa161e" checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
dependencies = [ dependencies = [
"jiff-static", "jiff-static",
"log", "log",
@@ -3166,9 +3170,9 @@ dependencies = [
[[package]] [[package]]
name = "jiff-static" name = "jiff-static"
version = "0.2.27" version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b605b0c050d845fc355bb11eb3f9a8deddc218ea60c76e61aa1f2adfb2c96a" checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -3351,9 +3355,9 @@ dependencies = [
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.30" version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
dependencies = [ dependencies = [
"serde_core", "serde_core",
] ]
@@ -3511,9 +3515,9 @@ dependencies = [
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
@@ -4412,7 +4416,7 @@ dependencies = [
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls 0.23.40", "rustls 0.23.40",
"socket2 0.6.3", "socket2 0.6.4",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@@ -4450,7 +4454,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
"once_cell", "once_cell",
"socket2 0.6.3", "socket2 0.6.4",
"tracing", "tracing",
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
@@ -4668,7 +4672,7 @@ dependencies = [
"http 1.4.1", "http 1.4.1",
"http-body 1.0.1", "http-body 1.0.1",
"http-body-util", "http-body-util",
"hyper 1.10.0", "hyper 1.10.1",
"hyper-rustls 0.27.9", "hyper-rustls 0.27.9",
"hyper-tls", "hyper-tls",
"hyper-util", "hyper-util",
@@ -4714,7 +4718,7 @@ dependencies = [
"http 1.4.1", "http 1.4.1",
"http-body 1.0.1", "http-body 1.0.1",
"http-body-util", "http-body-util",
"hyper 1.10.0", "hyper 1.10.1",
"hyper-rustls 0.27.9", "hyper-rustls 0.27.9",
"hyper-tls", "hyper-tls",
"hyper-util", "hyper-util",
@@ -4817,9 +4821,9 @@ checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
[[package]] [[package]]
name = "rpassword" name = "rpassword"
version = "7.5.3" version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "835a57a69104632d64deb0df2e09a69945cd7a6eab4070fc9b1d7e50cf6c3edc" checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [ dependencies = [
"libc", "libc",
"rtoolbox", "rtoolbox",
@@ -4944,9 +4948,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-native-certs" name = "rustls-native-certs"
version = "0.8.3" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [ dependencies = [
"openssl-probe", "openssl-probe",
"rustls-pki-types", "rustls-pki-types",
@@ -5034,15 +5038,6 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "scc"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
dependencies = [
"sdd",
]
[[package]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.29" version = "0.1.29"
@@ -5121,12 +5116,6 @@ dependencies = [
"untrusted", "untrusted",
] ]
[[package]]
name = "sdd"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]] [[package]]
name = "secrecy" name = "secrecy"
version = "0.10.3" version = "0.10.3"
@@ -5320,9 +5309,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.20.0" version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
dependencies = [ dependencies = [
"base64", "base64",
"bs58", "bs58",
@@ -5340,9 +5329,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_with_macros" name = "serde_with_macros"
version = "3.20.0" version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [ dependencies = [
"darling 0.23.0", "darling 0.23.0",
"proc-macro2", "proc-macro2",
@@ -5365,24 +5354,23 @@ dependencies = [
[[package]] [[package]]
name = "serial_test" name = "serial_test"
version = "3.4.0" version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d"
dependencies = [ dependencies = [
"futures-executor", "futures-executor",
"futures-util", "futures-util",
"log", "log",
"once_cell", "once_cell",
"parking_lot", "parking_lot",
"scc",
"serial_test_derive", "serial_test_derive",
] ]
[[package]] [[package]]
name = "serial_test_derive" name = "serial_test_derive"
version = "3.4.0" version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -5460,6 +5448,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]] [[package]]
name = "sigchld" name = "sigchld"
version = "0.2.4" version = "0.2.4"
@@ -5582,9 +5576,9 @@ dependencies = [
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.3" version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -6019,7 +6013,7 @@ dependencies = [
"parking_lot", "parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
"socket2 0.6.3", "socket2 0.6.4",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -6130,13 +6124,13 @@ dependencies = [
"http 1.4.1", "http 1.4.1",
"http-body 1.0.1", "http-body 1.0.1",
"http-body-util", "http-body-util",
"hyper 1.10.0", "hyper 1.10.1",
"hyper-timeout", "hyper-timeout",
"hyper-util", "hyper-util",
"percent-encoding", "percent-encoding",
"pin-project", "pin-project",
"rustls-native-certs", "rustls-native-certs",
"socket2 0.6.3", "socket2 0.6.4",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls 0.26.4", "tokio-rustls 0.26.4",
@@ -6311,9 +6305,9 @@ dependencies = [
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.0" version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "typespec" name = "typespec"
@@ -6386,9 +6380,9 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.13.2" version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
@@ -6523,9 +6517,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.1" version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
dependencies = [ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"js-sys", "js-sys",
@@ -7418,9 +7412,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [ dependencies = [
"stable_deref_trait", "stable_deref_trait",
"yoke-derive", "yoke-derive",
@@ -7441,18 +7435,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.49" version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.49" version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "coyote-ai" name = "coyote-ai"
version = "0.5.0" version = "0.6.0"
edition = "2024" edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"] authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "An all-in-one, batteries included LLM CLI Tool" description = "An all-in-one, batteries included LLM CLI Tool"
+5 -2
View File
@@ -507,7 +507,9 @@ open_link() {
guard_operation() { guard_operation() {
if [[ -z "$AUTO_CONFIRM" && -z "$LLM_AGENT_VAR_AUTO_CONFIRM" ]]; then 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 if [[ "$ans" == 0 ]]; then
error "Operation aborted!" 2>&1 error "Operation aborted!" 2>&1
@@ -657,7 +659,8 @@ guard_path() {
confirmation_prompt="$2" confirmation_prompt="$2"
if [[ ! "$path" == "$(pwd)"* && -z "$AUTO_CONFIRM" && -z "$LLM_AGENT_VAR_AUTO_CONFIRM" ]]; then 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 if [[ "$ans" == 0 ]]; then
error "Operation aborted!" >&2 error "Operation aborted!" >&2
+3
View File
@@ -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. As a professional Prompt Engineer, your role is to create effective and innovative prompts for interacting with AI models.
Your core skills include: Your core skills include:
+3
View File
@@ -1,3 +1,6 @@
---
skills_enabled: false
---
Create a concise, 3-6 word title. Create a concise, 3-6 word title.
**Notes**: **Notes**:
+3
View File
@@ -1,3 +1,6 @@
---
skills_enabled: false
---
Provide a terse, single sentence description of the given shell command. Provide a terse, single sentence description of the given shell command.
Describe each argument and option of the command. Describe each argument and option of the command.
Provide short responses in about 80 words. Provide short responses in about 80 words.
+3
View File
@@ -1,3 +1,6 @@
---
skills_enabled: false
---
Provide only {{__shell__}} commands for {{__os_distro__}} without any description. Provide only {{__shell__}} commands for {{__os_distro__}} without any description.
Ensure the output is a valid {{__shell__}} command. Ensure the output is a valid {{__shell__}} command.
If there is a lack of details, provide most logical solution. If there is a lack of details, provide most logical solution.
+4
View File
@@ -48,6 +48,10 @@ enabled_skills: # Optional list of skills available when this a
# Must be a subset of global `visible_skills`. Omit to inherit the global default. # Must be a subset of global `visible_skills`. Omit to inherit the global default.
- git-master - git-master
- ai-slop-remover - 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)
dynamic_instructions: false # Whether to use dynamic instructions for the agent; if false, static instructions are used 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 instructions: | # Static instructions for the agent; ignored if dynamic instructions are used
You are a AI agent designed to demonstrate agent capabilities. You are a AI agent designed to demonstrate agent capabilities.
+15 -11
View File
@@ -137,21 +137,25 @@ enabled_mcp_servers: null # Which MCP servers to enable by default.
# ---- Skills ---- # ---- Skills ----
# Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation. # 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. # 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_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. # 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". visible_skills: # The universe of skills allowed to be enabled in any context. Omit (null) for "all installed".
- ai-slop-remover - ai-slop-remover
- code-review - code-review
- frontend-ui-ux - frontend-ui-ux
- git-master - git-master
enabled_skills: null # Which skills are available by default (no role/agent/session active). null = all visible. 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. # Accepts either a YAML list or a comma-separated string.
# Example (list form): # Example (list form):
# enabled_skills: # enabled_skills:
# - git-master # - git-master
# - ai-slop-remover # - ai-slop-remover
# Example (comma-separated form): # Example (comma-separated form):
# enabled_skills: git-master,ai-slop-remover # 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) ---- # ---- Auto-Continue (Todo System) ----
# The auto-continue system provides built-in task tracking for improved reliability. # The auto-continue system provides built-in task tracking for improved reliability.
+4
View File
@@ -19,6 +19,10 @@ 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) 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`). - 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. - 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)
prompt: null # A custom prompt to use for this role that will immediately query prompt: null # A custom prompt to use for this role that will immediately query
# the model for output instead of using the instructions below # the model for output instead of using the instructions below
# Auto-Continue (Todo System) # Auto-Continue (Todo System)
+7
View File
@@ -63,6 +63,9 @@ enabled_skills:
- code-review - code-review
- git-master - git-master
- ai-slop-remover - 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 conversation_starters: # Suggested prompts surfaced in the UI
- "Research the current state of WebAssembly outside the browser" - "Research the current state of WebAssembly outside the browser"
@@ -173,8 +176,12 @@ nodes:
# catches violations at load time). `skills_enabled: false` would # catches violations at load time). `skills_enabled: false` would
# disable skills entirely for this node (no meta-tools exposed). # disable skills entirely for this node (no meta-tools exposed).
# Nothing is auto-loaded: the model decides when to load a skill. # 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: enabled_skills:
- ai-slop-remover - 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 output_schema: # Optional JSON Schema. The output is parsed to JSON
type: object # and its top-level object keys auto-merge into state type: object # and its top-level object keys auto-merge into state
properties: # (so `topic` / `needs_deep_dive` become {{topic}} etc). properties: # (so `topic` / `needs_deep_dive` become {{topic}} etc).
+14
View File
@@ -464,6 +464,14 @@ impl Agent {
self.config.continuation_prompt.clone() 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 { pub fn can_spawn_agents(&self) -> bool {
self.config.can_spawn_agents self.config.can_spawn_agents
} }
@@ -625,6 +633,10 @@ pub struct AgentConfig {
pub inject_todo_instructions: bool, pub inject_todo_instructions: bool,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub inject_spawn_instructions: bool, 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub compression_threshold: Option<usize>, pub compression_threshold: Option<usize>,
#[serde(default)] #[serde(default)]
@@ -704,6 +716,8 @@ impl AgentConfig {
mcp_servers: graph.mcp_servers.clone(), mcp_servers: graph.mcp_servers.clone(),
skills_enabled: graph.skills_enabled, skills_enabled: graph.skills_enabled,
enabled_skills: graph.enabled_skills.clone(), 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(), conversation_starters: graph.conversation_starters.clone(),
variables: graph.variables.clone(), variables: graph.variables.clone(),
can_spawn_agents: graph.has_agent_node(), can_spawn_agents: graph.has_agent_node(),
+6
View File
@@ -52,6 +52,8 @@ pub struct AppConfig {
pub max_auto_continues: usize, pub max_auto_continues: usize,
pub inject_todo_instructions: bool, pub inject_todo_instructions: bool,
pub continuation_prompt: Option<String>, pub continuation_prompt: Option<String>,
pub inject_skill_instructions: bool,
pub skill_instructions: Option<String>,
pub repl_prelude: Option<String>, pub repl_prelude: Option<String>,
pub cmd_prelude: Option<String>, pub cmd_prelude: Option<String>,
@@ -118,6 +120,8 @@ impl Default for AppConfig {
max_auto_continues: 10, max_auto_continues: 10,
inject_todo_instructions: true, inject_todo_instructions: true,
continuation_prompt: None, continuation_prompt: None,
inject_skill_instructions: true,
skill_instructions: None,
repl_prelude: None, repl_prelude: None,
cmd_prelude: None, cmd_prelude: None,
@@ -185,6 +189,8 @@ impl AppConfig {
max_auto_continues: config.max_auto_continues, max_auto_continues: config.max_auto_continues,
inject_todo_instructions: config.inject_todo_instructions, inject_todo_instructions: config.inject_todo_instructions,
continuation_prompt: config.continuation_prompt, continuation_prompt: config.continuation_prompt,
inject_skill_instructions: config.inject_skill_instructions,
skill_instructions: config.skill_instructions,
repl_prelude: config.repl_prelude, repl_prelude: config.repl_prelude,
cmd_prelude: config.cmd_prelude, cmd_prelude: config.cmd_prelude,
+6 -2
View File
@@ -6,7 +6,7 @@ mod install_remote;
mod macros; mod macros;
mod mcp_factory; mod mcp_factory;
pub(crate) mod paths; pub(crate) mod paths;
mod prompts; pub(crate) mod prompts;
mod rag_cache; mod rag_cache;
mod request_context; mod request_context;
mod role; mod role;
@@ -28,7 +28,7 @@ pub use self::app_state::AppState;
pub use self::input::Input; pub use self::input::Input;
pub use self::install_remote::{install_remote, install_remote_from_repl_args}; pub use self::install_remote::{install_remote, install_remote_from_repl_args};
#[allow(unused_imports)] #[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::{ pub use self::role::{
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE, CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
}; };
@@ -214,6 +214,8 @@ pub struct Config {
pub max_auto_continues: usize, pub max_auto_continues: usize,
pub inject_todo_instructions: bool, pub inject_todo_instructions: bool,
pub continuation_prompt: Option<String>, pub continuation_prompt: Option<String>,
pub inject_skill_instructions: bool,
pub skill_instructions: Option<String>,
pub repl_prelude: Option<String>, pub repl_prelude: Option<String>,
pub cmd_prelude: Option<String>, pub cmd_prelude: Option<String>,
@@ -280,6 +282,8 @@ impl Default for Config {
max_auto_continues: 10, max_auto_continues: 10,
inject_todo_instructions: true, inject_todo_instructions: true,
continuation_prompt: None, continuation_prompt: None,
inject_skill_instructions: true,
skill_instructions: None,
repl_prelude: None, repl_prelude: None,
cmd_prelude: None, cmd_prelude: None,
+8
View File
@@ -1,5 +1,13 @@
use indoc::indoc; 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(in crate::config) const DEFAULT_TODO_INSTRUCTIONS: &str = indoc! {" pub(in crate::config) const DEFAULT_TODO_INSTRUCTIONS: &str = indoc! {"
## Task Tracking ## Task Tracking
You have built-in task tracking tools. Use them to track your progress: You have built-in task tracking tools. Use them to track your progress:
+375 -5
View File
@@ -39,6 +39,7 @@ use indoc::formatdoc;
use inquire::{Confirm, MultiSelect, Text, list_option::ListOption, validator::Validation}; use inquire::{Confirm, MultiSelect, Text, list_option::ListOption, validator::Validation};
use log::warn; use log::warn;
use parking_lot::RwLock; use parking_lot::RwLock;
use prompts::DEFAULT_SKILL_INSTRUCTIONS;
use std::collections::{BTreeSet, HashMap, HashSet}; use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs::{File, OpenOptions, read_dir, read_to_string, remove_dir_all, remove_file}; use std::fs::{File, OpenOptions, read_dir, read_to_string, remove_dir_all, remove_file};
use std::io::Write; use std::io::Write;
@@ -53,6 +54,20 @@ pub struct AutoContinueConfig {
pub continuation_prompt: Option<String>, pub continuation_prompt: Option<String>,
} }
pub struct SkillInstructionsConfig {
pub inject: bool,
pub instructions: Option<String>,
}
/// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RenderMode { pub enum RenderMode {
#[default] #[default]
@@ -634,9 +649,62 @@ impl RequestContext {
self.agent.as_ref(), self.agent.as_ref(),
self.session.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),
);
}
}
Ok(self.skill_registry.effective_role(&role, &policy)) 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 auto_continue_config(&self) -> AutoContinueConfig { pub fn auto_continue_config(&self) -> AutoContinueConfig {
if let Some(agent) = &self.agent { if let Some(agent) = &self.agent {
return AutoContinueConfig { return AutoContinueConfig {
@@ -1207,7 +1275,8 @@ impl RequestContext {
.iter() .iter()
.filter(|v| { .filter(|v| {
(v.name.starts_with(USER_FUNCTION_PREFIX) (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) && !existing.contains(&v.name)
}) })
.cloned() .cloned()
@@ -1707,7 +1776,7 @@ impl RequestContext {
} }
let value = match key { let value = match key {
"continuation_prompt" => raw_value, "continuation_prompt" | "skill_instructions" => raw_value,
_ => { _ => {
if raw_value.contains(char::is_whitespace) { if raw_value.contains(char::is_whitespace) {
bail!("Usage: .set <key> <value>. If value is null, unset key."); bail!("Usage: .set <key> <value>. If value is null, unset key.");
@@ -1907,6 +1976,22 @@ impl RequestContext {
self.update_app_config(|app| app.continuation_prompt = value); 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);
}
}
_ => bail!("Unknown key '{key}'"), _ => bail!("Unknown key '{key}'"),
} }
Ok(()) Ok(())
@@ -2006,6 +2091,8 @@ impl RequestContext {
"enabled_tools", "enabled_tools",
"enabled_mcp_servers", "enabled_mcp_servers",
"inject_todo_instructions", "inject_todo_instructions",
"inject_skill_instructions",
"skill_instructions",
"max_auto_continues", "max_auto_continues",
"save_session", "save_session",
"compression_threshold", "compression_threshold",
@@ -2172,6 +2259,11 @@ impl RequestContext {
super::complete_bool(config.inject_instructions) super::complete_bool(config.inject_instructions)
} }
"continuation_prompt" => vec!["null".to_string()], "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()],
_ => vec![], _ => vec![],
}; };
values = candidates.into_iter().map(|v| (v, None)).collect(); values = candidates.into_iter().map(|v| (v, None)).collect();
@@ -2981,11 +3073,12 @@ mod tests {
use super::super::mcp_factory::McpFactory; use super::super::mcp_factory::McpFactory;
use super::*; use super::*;
use crate::config::AppState; use crate::config::AppState;
use crate::function::ToolCall; use crate::function::{ToolCall, skill};
use crate::mcp::{McpServer, McpServersConfig, McpTransportType}; use crate::mcp::{McpServer, McpServersConfig, McpTransportType};
use crate::utils; use crate::utils;
use crate::utils::get_env_name; use crate::utils::get_env_name;
use crate::vault::Vault; use crate::vault::Vault;
use serde_json::json;
use serial_test::serial; use serial_test::serial;
use std::env; use std::env;
use std::fs::{create_dir_all, remove_dir_all, write}; use std::fs::{create_dir_all, remove_dir_all, write};
@@ -3123,6 +3216,108 @@ mod tests {
assert_eq!(extracted.name(), ""); 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] #[test]
fn exit_session_clears_session() { fn exit_session_clears_session() {
let mut ctx = create_test_ctx(); let mut ctx = create_test_ctx();
@@ -3448,6 +3643,182 @@ mod tests {
assert!(!names.contains(&"todo__done")); 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] #[test]
fn select_enabled_mcp_servers_returns_empty_when_mcp_disabled() { fn select_enabled_mcp_servers_returns_empty_when_mcp_disabled() {
let app_state = { let app_state = {
@@ -3677,8 +4048,7 @@ mod tests {
let input = Input::from_str(&ctx, "hello", None).unwrap(); let input = Input::from_str(&ctx, "hello", None).unwrap();
let app = Arc::clone(&ctx.app.config); let app = Arc::clone(&ctx.app.config);
let tool_result = let tool_result = ToolResult::new(crate::function::ToolCall::default(), json!({}));
ToolResult::new(crate::function::ToolCall::default(), serde_json::json!({}));
ctx.after_chat_completion(app.as_ref(), &input, "", &[tool_result]) ctx.after_chat_completion(app.as_ref(), &input, "", &[tool_result])
.unwrap(); .unwrap();
+24
View File
@@ -79,6 +79,10 @@ pub struct Role {
inject_todo_instructions: Option<bool>, inject_todo_instructions: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
continuation_prompt: Option<String>, 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)] #[serde(skip)]
model: Model, model: Model,
@@ -124,6 +128,10 @@ impl Role {
"continuation_prompt" => { "continuation_prompt" => {
role.continuation_prompt = value.as_str().map(|v| v.to_string()) 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())
}
_ => (), _ => (),
} }
} }
@@ -189,6 +197,14 @@ impl Role {
if let Some(continuation_prompt) = &self.continuation_prompt { if let Some(continuation_prompt) = &self.continuation_prompt {
metadata.push(format!("continuation_prompt: {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 metadata.is_empty() { if metadata.is_empty() {
format!("{}\n", self.prompt) format!("{}\n", self.prompt)
} else if self.prompt.is_empty() { } else if self.prompt.is_empty() {
@@ -299,6 +315,14 @@ impl Role {
self.continuation_prompt.as_deref() 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 skills_enabled(&self) -> Option<bool> { pub fn skills_enabled(&self) -> Option<bool> {
self.skills_enabled self.skills_enabled
} }
+41
View File
@@ -56,6 +56,10 @@ pub struct Session {
inject_todo_instructions: Option<bool>, inject_todo_instructions: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
continuation_prompt: Option<String>, 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")] #[serde(skip_serializing_if = "Option::is_none")]
role_name: Option<String>, role_name: Option<String>,
@@ -227,6 +231,12 @@ impl Session {
if let Some(continuation_prompt) = self.continuation_prompt() { if let Some(continuation_prompt) = self.continuation_prompt() {
data["continuation_prompt"] = continuation_prompt.into(); 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();
}
let (tokens, percent) = self.tokens_usage(); let (tokens, percent) = self.tokens_usage();
data["total_tokens"] = tokens.into(); data["total_tokens"] = tokens.into();
if let Some(max_input_tokens) = self.model().max_input_tokens() { if let Some(max_input_tokens) = self.model().max_input_tokens() {
@@ -305,6 +315,15 @@ impl Session {
if let Some(continuation_prompt) = self.continuation_prompt() { if let Some(continuation_prompt) = self.continuation_prompt() {
items.push(("continuation_prompt", continuation_prompt.to_string())); 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(max_input_tokens) = self.model().max_input_tokens() { if let Some(max_input_tokens) = self.model().max_input_tokens() {
items.push(("max_input_tokens", max_input_tokens.to_string())); items.push(("max_input_tokens", max_input_tokens.to_string()));
@@ -446,6 +465,14 @@ impl Session {
self.continuation_prompt.as_deref() 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 set_inject_todo_instructions(&mut self, value: Option<bool>) { pub fn set_inject_todo_instructions(&mut self, value: Option<bool>) {
if self.inject_todo_instructions != value { if self.inject_todo_instructions != value {
self.inject_todo_instructions = value; self.inject_todo_instructions = value;
@@ -460,6 +487,20 @@ 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_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 { pub fn needs_compression(&self, global_compression_threshold: usize) -> bool {
if self.compressing { if self.compressing {
return false; return false;
+251 -29
View File
@@ -3,14 +3,16 @@ use super::app_config::AppConfig;
use super::paths; use super::paths;
use super::role::Role; use super::role::Role;
use super::session::Session; use super::session::Session;
use super::skill::Skill;
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use std::collections::HashSet; use std::collections::{BTreeSet, HashSet};
#[derive(Debug)] #[derive(Debug)]
pub struct SkillPolicy { pub struct SkillPolicy {
pub skills_enabled: bool, pub skills_enabled: bool,
pub enabled: HashSet<String>, pub enabled: HashSet<String>,
pub compatible_enabled: BTreeSet<String>,
} }
impl SkillPolicy { impl SkillPolicy {
@@ -27,20 +29,27 @@ impl SkillPolicy {
session, session,
&paths::has_skill, &paths::has_skill,
&paths::list_skills, &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, global: &AppConfig,
role: Option<&Role>, role: Option<&Role>,
agent: Option<&Agent>, agent: Option<&Agent>,
session: Option<&Session>, session: Option<&Session>,
skill_exists: &F, skill_exists: &F,
list_installed: &G, list_installed: &G,
skill_is_compatible: &H,
) -> Result<Self> ) -> Result<Self>
where where
F: Fn(&str) -> bool, F: Fn(&str) -> bool,
G: Fn() -> Vec<String>, G: Fn() -> Vec<String>,
H: Fn(&str, bool) -> bool,
{ {
let mut skills_enabled = global.skills_enabled; let mut skills_enabled = global.skills_enabled;
if let Some(r) = role 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 { Ok(Self {
skills_enabled, skills_enabled,
enabled, enabled,
compatible_enabled,
}) })
} }
@@ -128,6 +149,10 @@ mod tests {
Vec::new() Vec::new()
} }
fn all_compatible(_: &str, _: bool) -> bool {
true
}
fn make_app_config( fn make_app_config(
skills_enabled: bool, skills_enabled: bool,
enabled: Option<&str>, enabled: Option<&str>,
@@ -145,9 +170,16 @@ mod tests {
fn defaults_yield_skills_enabled_with_empty_universe() { fn defaults_yield_skills_enabled_with_empty_universe() {
let global = AppConfig::default(); let global = AppConfig::default();
let policy = let policy = SkillPolicy::effective_with(
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed) &global,
.unwrap(); None,
None,
None,
&always_true,
&empty_installed,
&all_compatible,
)
.unwrap();
assert!(policy.skills_enabled); assert!(policy.skills_enabled);
assert!(policy.enabled.is_empty()); assert!(policy.enabled.is_empty());
@@ -158,9 +190,16 @@ mod tests {
let global = AppConfig::default(); let global = AppConfig::default();
let installed = || vec!["alpha".to_string(), "beta".to_string()]; let installed = || vec!["alpha".to_string(), "beta".to_string()];
let policy = let policy = SkillPolicy::effective_with(
SkillPolicy::effective_with(&global, None, None, None, &always_true, &installed) &global,
.unwrap(); None,
None,
None,
&always_true,
&installed,
&all_compatible,
)
.unwrap();
assert_eq!(policy.enabled.len(), 2); assert_eq!(policy.enabled.len(), 2);
assert!(policy.enabled.contains("alpha")); assert!(policy.enabled.contains("alpha"));
@@ -171,9 +210,16 @@ mod tests {
fn falls_back_to_visible_when_visible_set_but_no_enabled() { fn falls_back_to_visible_when_visible_set_but_no_enabled() {
let global = make_app_config(true, None, Some(&["alpha", "beta"])); let global = make_app_config(true, None, Some(&["alpha", "beta"]));
let policy = let policy = SkillPolicy::effective_with(
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed) &global,
.unwrap(); None,
None,
None,
&always_true,
&empty_installed,
&all_compatible,
)
.unwrap();
assert_eq!(policy.enabled.len(), 2); assert_eq!(policy.enabled.len(), 2);
assert!(policy.enabled.contains("alpha")); assert!(policy.enabled.contains("alpha"));
@@ -184,9 +230,16 @@ mod tests {
fn global_enabled_skills_is_effective_when_no_other_levels() { fn global_enabled_skills_is_effective_when_no_other_levels() {
let global = make_app_config(true, Some("alpha,beta"), Some(&["alpha", "beta", "gamma"])); let global = make_app_config(true, Some("alpha,beta"), Some(&["alpha", "beta", "gamma"]));
let policy = let policy = SkillPolicy::effective_with(
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed) &global,
.unwrap(); None,
None,
None,
&always_true,
&empty_installed,
&all_compatible,
)
.unwrap();
assert!(policy.enabled.contains("alpha")); assert!(policy.enabled.contains("alpha"));
assert!(policy.enabled.contains("beta")); assert!(policy.enabled.contains("beta"));
@@ -205,6 +258,7 @@ mod tests {
None, None,
&always_true, &always_true,
&empty_installed, &empty_installed,
&all_compatible,
) )
.unwrap(); .unwrap();
@@ -224,6 +278,7 @@ mod tests {
None, None,
&always_true, &always_true,
&empty_installed, &empty_installed,
&all_compatible,
) )
.unwrap(); .unwrap();
@@ -237,9 +292,15 @@ mod tests {
..AppConfig::default() ..AppConfig::default()
}; };
let policy = SkillPolicy::effective_with(&global, None, None, None, &always_true, &|| { let policy = SkillPolicy::effective_with(
vec!["alpha".to_string()] &global,
}) None,
None,
None,
&always_true,
&|| vec!["alpha".to_string()],
&all_compatible,
)
.unwrap(); .unwrap();
assert!(!policy.allows("alpha")); assert!(!policy.allows("alpha"));
@@ -249,9 +310,16 @@ mod tests {
fn allows_returns_true_when_skill_in_enabled_set() { fn allows_returns_true_when_skill_in_enabled_set() {
let global = make_app_config(true, Some("alpha"), None); let global = make_app_config(true, Some("alpha"), None);
let policy = let policy = SkillPolicy::effective_with(
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed) &global,
.unwrap(); None,
None,
None,
&always_true,
&empty_installed,
&all_compatible,
)
.unwrap();
assert!(policy.allows("alpha")); assert!(policy.allows("alpha"));
assert!(!policy.allows("beta")); assert!(!policy.allows("beta"));
@@ -261,9 +329,16 @@ mod tests {
fn validation_rejects_uninstalled_skill_reference() { fn validation_rejects_uninstalled_skill_reference() {
let global = make_app_config(true, Some("ghost"), None); let global = make_app_config(true, Some("ghost"), None);
let err = let err = SkillPolicy::effective_with(
SkillPolicy::effective_with(&global, None, None, None, &|_| false, &empty_installed) &global,
.unwrap_err(); None,
None,
None,
&|_| false,
&empty_installed,
&all_compatible,
)
.unwrap_err();
assert!(err.to_string().contains("not installed")); assert!(err.to_string().contains("not installed"));
assert!(err.to_string().contains("ghost")); assert!(err.to_string().contains("ghost"));
@@ -273,9 +348,16 @@ mod tests {
fn validation_rejects_skill_not_in_visible_set() { fn validation_rejects_skill_not_in_visible_set() {
let global = make_app_config(true, Some("beta"), Some(&["alpha"])); let global = make_app_config(true, Some("beta"), Some(&["alpha"]));
let err = let err = SkillPolicy::effective_with(
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed) &global,
.unwrap_err(); None,
None,
None,
&always_true,
&empty_installed,
&all_compatible,
)
.unwrap_err();
assert!( assert!(
err.to_string() err.to_string()
@@ -288,9 +370,16 @@ mod tests {
fn validation_skipped_when_no_explicit_enabled_skills() { fn validation_skipped_when_no_explicit_enabled_skills() {
let global = make_app_config(true, None, None); let global = make_app_config(true, None, None);
let policy = let policy = SkillPolicy::effective_with(
SkillPolicy::effective_with(&global, None, None, None, &|_| false, &empty_installed) &global,
.unwrap(); None,
None,
None,
&|_| false,
&empty_installed,
&all_compatible,
)
.unwrap();
assert!(policy.enabled.is_empty()); assert!(policy.enabled.is_empty());
} }
@@ -307,9 +396,142 @@ mod tests {
None, None,
&always_true, &always_true,
&empty_installed, &empty_installed,
&all_compatible,
) )
.unwrap(); .unwrap();
assert!(policy.enabled.is_empty()); 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"
);
}
} }
+1
View File
@@ -116,6 +116,7 @@ impl SkillRegistry {
let policy = SkillPolicy { let policy = SkillPolicy {
skills_enabled: true, skills_enabled: true,
enabled: self.loaded.keys().cloned().collect(), enabled: self.loaded.keys().cloned().collect(),
compatible_enabled: self.loaded.keys().cloned().collect(),
}; };
self.effective_role(base, &policy) self.effective_role(base, &policy)
} }
+10 -15
View File
@@ -14,9 +14,11 @@ pub fn skill_function_declarations() -> Vec<FunctionDeclaration> {
FunctionDeclaration { FunctionDeclaration {
name: format!("{SKILL_FUNCTION_PREFIX}list"), name: format!("{SKILL_FUNCTION_PREFIX}list"),
description: description:
"List skills available in this context. Returns each skill's name, description, \ "List skills available in this context. Call this early in any non-trivial task to \
what tools and MCP servers it grants on load, and whether it is currently loaded. \ discover specialized skills that may apply to the work before deciding on an \
Call this to discover skills before using skill__load." 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(), .to_string(),
parameters: JsonSchema { parameters: JsonSchema {
type_value: Some("object".to_string()), type_value: Some("object".to_string()),
@@ -28,9 +30,10 @@ pub fn skill_function_declarations() -> Vec<FunctionDeclaration> {
FunctionDeclaration { FunctionDeclaration {
name: format!("{SKILL_FUNCTION_PREFIX}load"), name: format!("{SKILL_FUNCTION_PREFIX}load"),
description: description:
"Load a skill module into the current context. The skill's instructions and any \ "Load a skill module into the current context after confirming via `skill__list` \
tools or MCP servers it grants become active for subsequent turns. Call \ that it applies to the task at hand. The skill's instructions and any tools or \
skill__unload when the skill's work is complete to keep the context lean." 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(), .to_string(),
parameters: JsonSchema { parameters: JsonSchema {
type_value: Some("object".to_string()), 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> { 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() { let visible_names: Vec<String> = match ctx.app.config.visible_skills.as_deref() {
Some(list) => list.to_vec(), Some(list) => list.to_vec(),
None => paths::list_skills(), None => paths::list_skills(),
@@ -111,7 +112,7 @@ fn handle_list(ctx: &RequestContext, policy: &SkillPolicy) -> Result<Value> {
let mut entries = Vec::new(); let mut entries = Vec::new();
for name in visible_names { for name in visible_names {
if !policy.allows(&name) { if !policy.compatible_enabled.contains(&name) {
continue; continue;
} }
@@ -122,12 +123,6 @@ fn handle_list(ctx: &RequestContext, policy: &SkillPolicy) -> Result<Value> {
continue; 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!({ entries.push(json!({
"name": skill.name(), "name": skill.name(),
+31 -1
View File
@@ -2,7 +2,10 @@ use super::state::StateManager;
use super::structured; use super::structured;
use super::types::LlmNode; use super::types::LlmNode;
use crate::client::{Model, ModelType, call_chat_completions}; 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::skill::skill_function_declarations;
use crate::utils::create_abort_signal; use crate::utils::create_abort_signal;
use anyhow::{Context, Error, Result, anyhow, bail}; use anyhow::{Context, Error, Result, anyhow, bail};
@@ -139,6 +142,31 @@ async fn run(
role.set_enabled_tools(Some(tools)); 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 composed_role = parent_ctx.skill_registry.effective_role(&role, &policy);
let saved_role = parent_ctx.role.clone(); let saved_role = parent_ctx.role.clone();
@@ -456,6 +484,8 @@ mod tests {
timeout: None, timeout: None,
skills_enabled: None, skills_enabled: None,
enabled_skills: None, enabled_skills: None,
inject_skill_instructions: None,
skill_instructions: None,
} }
} }
+12
View File
@@ -37,6 +37,12 @@ pub struct Graph {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled_skills: Option<Vec<String>>, 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)] #[serde(default)]
pub conversation_starters: Vec<String>, pub conversation_starters: Vec<String>,
@@ -305,6 +311,12 @@ pub struct LlmNode {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled_skills: Option<Vec<String>>, 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 { fn default_llm_max_attempts() -> u32 {
+4
View File
@@ -950,6 +950,8 @@ mod tests {
mcp_servers: Vec::new(), mcp_servers: Vec::new(),
skills_enabled: None, skills_enabled: None,
enabled_skills: None, enabled_skills: None,
inject_skill_instructions: None,
skill_instructions: None,
conversation_starters: Vec::new(), conversation_starters: Vec::new(),
variables: Vec::new(), variables: Vec::new(),
settings: GraphSettings::default(), settings: GraphSettings::default(),
@@ -1051,6 +1053,8 @@ mod tests {
timeout: None, timeout: None,
skills_enabled: None, skills_enabled: None,
enabled_skills: None, enabled_skills: None,
inject_skill_instructions: None,
skill_instructions: None,
}), }),
next: next.map(NextTargets::from), next: next.map(NextTargets::from),
} }