Compare commits
531 Commits
cae279c9e0
..
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| bca25404ab | |||
| 161fa2d983 | |||
| 0e93775491 | |||
| c00c4ff84a | |||
| 46685cb641 | |||
| 165d0d113d | |||
| 70dc7c9680 | |||
| 4eac536327 | |||
| 8e0fa79ff3 | |||
| 68a912ec38 | |||
| f405ec5e16 | |||
| b997e9493c | |||
| 8d6e9bef32 | |||
| e54a2e42c9 | |||
| b1696c3425 | |||
| feef3f67b5 | |||
| dc066bee0d | |||
| 6c4e042dad | |||
| 30f3b01358 | |||
| ebf3b5f776 | |||
| 84dcb3078b | |||
| 7b320e08c4 | |||
| 7078280b3d | |||
| 43607dbe8d | |||
| 8f7a57f8e6 | |||
| 40fdf3aaa7 | |||
| 46d4b78ccc | |||
| b0a3b0a9a5 | |||
| 53b3ce9ab1 | |||
| 44f533018e | |||
| bbb23f4884 | |||
| 8de0eef4f9 | |||
| 73a4499c68 | |||
| 97100bee29 | |||
| 9a25438643 | |||
| f6da937c5d | |||
| eeaeb42c9a | |||
| 1dde7f4442 | |||
| 9879980304 | |||
| 7ec81ae607 | |||
| dac2a16677 | |||
| 260bf4e5bc | |||
| ece66448e0 | |||
| a254d60876 | |||
| c36c4f4699 | |||
| 4a14d80d97 | |||
| c6a9268856 | |||
| 2914a1070b | |||
| 5ebf8649a6 | |||
| 0272412334 | |||
| 7a7824be6a | |||
| aa2d4f3265 | |||
| 28a283283f | |||
| 652ab0b180 | |||
| 8ad764527d | |||
| bba094086d | |||
| 658ca7fec3 | |||
| 156de15a33 | |||
| 695a684b8d | |||
| 307e2cfc50 | |||
| ed59f793fc | |||
| c17db05f39 | |||
| b1782b614f | |||
| 2acff31213 | |||
| a564085449 | |||
| 2d5cdb96d2 | |||
| 5a47a6637f | |||
| 625a251931 | |||
| d0ebe7408f | |||
| 976ba7066d | |||
| ff3789f869 | |||
| 744dd213f5 | |||
| f6b4bf05b6 | |||
| 94e3c3535c | |||
| 31b44fbeb7 | |||
| 07f4b134b6 | |||
| 5c374bb5bf | |||
| 0f90dd5f53 | |||
| d07caf2a4b | |||
| 81a2bd1d00 | |||
| 5fa6ffb81d | |||
| 1faab15377 | |||
| a4ddc3d65d | |||
| 588c69ea6c | |||
| bf8dad2a4f | |||
| 2e06c0e7d2 | |||
| de42cae87f | |||
| cdc4bd154a | |||
| aa2e627a5f | |||
| 3359c62429 | |||
| 75a6a5e145 | |||
| a9cad501ff | |||
| 26584c7500 | |||
| 62fdf4a2b5 | |||
| 296aa6f50f | |||
| 93cc498731 | |||
| b1cd8351fa | |||
| ccf5e73341 | |||
| be5d280c32 | |||
| 6633a8c0bf | |||
| 097d8936e3 | |||
| 8a53b7934b | |||
| 0facb15e32 | |||
| c172736362 | |||
| 4a2b9fa42a | |||
| 98db37866c | |||
| ad31fbd169 | |||
| d69e28fd39 | |||
| 279eaa5300 | |||
| e687d78931 | |||
| 0c2e4df647 | |||
| 6221875f64 | |||
| 895b9c27db | |||
| e661ca2eda | |||
| 7066edd904 | |||
| 61bdf29bea | |||
| ef39c7d9ff | |||
| e9e46158e7 | |||
| 34dc4b0dce | |||
| cd226577e7 | |||
| b5fc633454 | |||
| 484b18ef16 | |||
| 7333046cfe | |||
| 815f0e5c39 | |||
| dacccbfcf7 | |||
| 5370637274 | |||
| e6da252a5a | |||
| 4aaff21f45 | |||
| 2678afe02b | |||
| 558b764db8 | |||
| 0bb312a85c | |||
| d81d233527 | |||
| 597f823bdf | |||
| 81c037515e | |||
| 3c7d19da07 | |||
| 4536d00067 | |||
| 98d16d9a56 | |||
| 26de81e84e | |||
| 20c28b55d5 | |||
| 7d6f1dda26 | |||
| 9a061944ae | |||
| 1f50af0974 | |||
| bdacf9fc78 | |||
| a9f2a5edc2 | |||
| 2df8b1a541 | |||
| de055bf8a4 | |||
| 8fb0eece4b | |||
| ba03c3037d | |||
| afa0e4af67 | |||
| 5a9a00bc6f | |||
| e7bb668ac7 | |||
| 04498b96ec | |||
| eb2843d38a | |||
| 696ce03ee4 | |||
| a3d67bfbf7 | |||
| 5bd0766a60 | |||
| 35e1b14843 | |||
| 503c9b4699 | |||
| 7a8b09542d | |||
| da5cd21c1c | |||
| 27fcb1fc15 | |||
| e292c414c5 | |||
| 8a2f18204f | |||
| c70ac98223 | |||
| 249d1fc881 | |||
| 3f4fd91b3f | |||
| 48c52b5829 | |||
| f58f751c59 | |||
| fc7fdc98b4 | |||
| f4d7d0fb73 | |||
| 4b38f53488 | |||
| 186422ff58 | |||
| 9bc4f8b621 | |||
| 84497d3d65 | |||
| 3ea9116a23 | |||
| bfcd73c32a | |||
| 3cd3ba55ff | |||
| 3535edba79 | |||
| bf0343e245 | |||
| b001ae4c18 | |||
| 9ce088a530 | |||
| 16f3f71188 | |||
| 0af5fa02f9 | |||
| d6a0676264 | |||
| b582bab17c | |||
| a8732c63d6 | |||
| 389d0b768f | |||
| 70a251a7e2 | |||
| 462f136596 | |||
| bf9d7d750e | |||
| 540ec648c9 | |||
| e69352ee2d | |||
| ee4e3bc13f | |||
| a576961bd6 | |||
| 59c7fc1276 | |||
| bcf512fcfc | |||
| 195401c496 | |||
| 34d8d20ec6 | |||
| 08ba6f0446 | |||
| 26984892af | |||
| 526a426073 | |||
| c53e0546d4 | |||
| 349b3748bd | |||
| e23e5f9f7b | |||
| 8d02782de6 | |||
| 27ceefdb40 | |||
| 5168eb6781 | |||
| ddb73a9a33 | |||
| 53eff10d75 | |||
| 1df6114ff3 | |||
| 975484cc2b | |||
| 0421c9b643 | |||
| fb69c21252 | |||
| 0cb9122d16 | |||
| c164ad3cbb | |||
| 9b4171a468 | |||
| 5cae4e44fb | |||
| a145a42b2b | |||
| 715807645a | |||
| 1259c6865f | |||
| ff42460cb4 | |||
| 39a16f8d56 | |||
| 83de60f59c | |||
| cf60e090a5 | |||
| 0fb37c33ab | |||
| d81508c22a | |||
| 883ac659b2 | |||
| c6c10b5e24 | |||
| a4e5bef1b7 | |||
| f72c7b03f9 | |||
| bd6f709374 | |||
| 00f2201157 | |||
| b3f0d66071 | |||
| 8730d413bc | |||
| 79140fda3c | |||
| 67e749ea3a | |||
| 7bcfc133ae | |||
| e3e246607e | |||
| 16104cb2c5 | |||
| 224e51c386 | |||
| b022ca089c | |||
| 0ebb761c09 | |||
| c8067828d5 | |||
| 30eedd9b8c | |||
| d701b45057 | |||
| 722c9c101e | |||
| 86aa45f0c4 | |||
| cf45dc4820 | |||
| db77034431 | |||
| abdaec11b0 | |||
| 95fb349656 | |||
| d0b6b6c324 | |||
| d74c23ccf5 | |||
| ea1cfda0d6 | |||
| 5623f47f9a | |||
| e4df9ec193 | |||
| a6306d6b76 | |||
| 64529ba5cc | |||
| cc7f963b89 | |||
| 0ce86af116 | |||
| 2cb0ed3f64 | |||
| fb61854f11 | |||
| 53ba3344b1 | |||
| e20c8be8bb | |||
| 894dcb1d3c | |||
| 9a9e890f8a | |||
| 818ea634f0 | |||
| 780460f8d8 | |||
| e19483a920 | |||
| aca93f1cae | |||
| 1371a4aad2 | |||
| db4a45c0f6 | |||
| e95b1e5f82 | |||
| 15f4008f4b | |||
| f45f81fb45 | |||
| 2220fd2542 | |||
| 564480e165 | |||
| 297c63d91a | |||
| 26e2cd3f65 | |||
| 9f899466d4 | |||
| 38393ea4cf | |||
| a4f25826e3 | |||
| 93484fb33f | |||
| c90f003f92 | |||
| 24793b9b8d | |||
| 78e772f455 | |||
| 1e0d269aad | |||
| f6b1d408fc | |||
| 442b318b6c | |||
| a7c97aedb7 | |||
| 746f9e7b24 | |||
| 0d6c61af5c | |||
| 673f31c059 | |||
| 369a4f0a89 | |||
| 8d54eae4d0 | |||
| a805d5beab | |||
| dbb2aec8b6 | |||
| 1a98b76a1f | |||
| 51d10ab2b5 | |||
| 1aad750395 | |||
| e0aab6bd02 | |||
| 6cb93132b7 | |||
| 04126b99d6 | |||
| 0794eb960d | |||
| d619ad1d48 | |||
| 5b147e07b3 | |||
| 944ce441d8 | |||
| a7dcb8519b | |||
| d912d44fb3 | |||
| 4f7254a634 | |||
| bf923cb296 | |||
| d9f737e1bf | |||
| 59690d045e | |||
| 5d95acba53 | |||
| d46225d2a9 | |||
| 3af30a0e62 | |||
| 69eca4d96d | |||
| 7b2e4a83c9 | |||
| 344b80872a | |||
| ddf828ff5f | |||
| 4e170b069b | |||
| 22c75fb578 | |||
| 11ab9eb6b8 | |||
| 29b232f407 | |||
| 53e8c920e5 | |||
| 78d19bed4d | |||
| 10f4160635 | |||
| 7622836e8b | |||
| 4d4713a9fa | |||
| 25008599f9 | |||
| c00ab074f8 | |||
| aed1f1957f | |||
| c6a959e2e1 | |||
| 02b7ed37f6 | |||
| 0d84aaabb9 | |||
| 6efdcf9610 | |||
| 4266d317d8 | |||
| 4ce7aafcbd | |||
| 35d8b69f92 | |||
| 562057e608 | |||
| b7024e5340 | |||
| 088588231b | |||
| eff117d3d9 | |||
| 968c535709 | |||
| c8b6fa7b11 | |||
| 0aa334b54e | |||
| 78a49f841d | |||
| 43b2bd937e | |||
| a4326875ba | |||
| eb31a58346 | |||
| a6b0acc35d | |||
| cc7fcd0b5b | |||
| 02fe59b913 | |||
| 6fd5f47089 | |||
| 2a2922760e | |||
| a3793460fd | |||
| e0927a04d9 | |||
| 8665604bab | |||
| d4c3c135b3 | |||
| 60bd5e493c | |||
| 0753b2d841 | |||
| 17e6fbd692 | |||
| 0710441650 | |||
| 20a76cee3e | |||
| cb64785867 | |||
| e6e26103c4 | |||
| 15529a14f1 | |||
| 86839188e0 | |||
| 39701b378b | |||
| 45ff6da737 | |||
| a260dd1503 | |||
| 57859301df | |||
| 8c968d3f53 | |||
| 0034bfbe46 | |||
| a733b9247a | |||
| e0afa349b9 | |||
| 7d0ce94907 | |||
| 9045763c35 | |||
| 29898552d7 | |||
| 9d7c2f5c2f | |||
| 5c0fa42351 | |||
| ab045b0ef3 | |||
| 41e6843db1 | |||
| 911ec3c9b9 | |||
| fc6f0a1a7b | |||
| 21873da278 | |||
| d1cd6be2c9 | |||
| 0c0ae41bca | |||
| c9ed7a904a | |||
| d200a8f554 | |||
| 3d04c8fcf1 | |||
| f53f165d91 | |||
| e5645e4064 | |||
| 95e15ca8c4 | |||
| dbf7329e87 | |||
| ed6c3ae431 | |||
| 214d2ecc67 | |||
| 29c95671de | |||
| 238f93a096 | |||
| c76877e7b3 | |||
| 12e5a9c5aa | |||
| 7f4be2ca3f | |||
| 29ffe12d8c | |||
| d34bed4f15 | |||
| aec7ea7e80 | |||
| 5938e1af29 | |||
| 60902297c5 | |||
| 12a95aa6fa | |||
| 78fc459a97 | |||
| 281565804c | |||
| 33a32fd9c8 | |||
| b64aad55e9 | |||
| 2392958114 | |||
| ec04e8e24a | |||
| 4e14ee7f50 | |||
| 7ba4ab0608 | |||
| fd816112fb | |||
| d0ee85be40 | |||
| 9448704af3 | |||
| 9dad9d6ca8 | |||
| 3f41abed7c | |||
| debcbab445 | |||
| 7fcabf1de7 | |||
| e116a1841d | |||
| cd3103ca14 | |||
| 50d07a4b13 | |||
| ed1352936e | |||
| f4b4156a0c | |||
| 5cf2cce0e3 | |||
| 249453d829 | |||
| c14939cecc | |||
| 72f516abb1 | |||
| 66478ed264 | |||
| 6b10dff41d | |||
| f8cc736482 | |||
| a0794fecfc | |||
| c68059e5b3 | |||
| 832ca6b0de | |||
| 89ee43830e | |||
| f7cf13901e | |||
| ad41fa93fb | |||
| 617b7dcd49 | |||
| 417ea032c4 | |||
| b77bb6e200 | |||
| 1fa3b4a600 | |||
| 99bd502f62 | |||
| 25a271dc95 | |||
| 5002ac7716 | |||
| d92a559460 | |||
| 3d571e1a31 | |||
| d338daa4b6 | |||
| 6f802c2a58 | |||
| a3f0168817 | |||
| 677702655f | |||
| b0bbd0c083 | |||
| 5cbf23a1f4 | |||
| 39eb9b34ec | |||
| 5da8616518 | |||
| b267fe05cd | |||
| 29f7ebe559 | |||
| bbffaca511 | |||
| 80532836c3 | |||
| 9474f4f322 | |||
| 93a09d3a9f | |||
| e3935ce699 | |||
| 58c15e7833 | |||
| fd2b7f3aa0 | |||
| 5ccbc629d1 | |||
| e98ff5e8e5 | |||
| a6fffa7b57 | |||
| 3ac153dd06 | |||
| 8db3108c94 | |||
| e25ff4ad19 | |||
| 21e76c6461 | |||
| 103aa1a432 | |||
| d2f4fefcf3 | |||
| 629527988d | |||
| 7f520f1346 | |||
| e28619b55a | |||
| f474e6130e | |||
| 4b5bcb45ac | |||
| 50565a0f17 | |||
| cf37db4fa2 | |||
| ad9b4097ef | |||
| c22c01c6c3 | |||
| 31f7f50c4a | |||
| a7f6ed4b16 | |||
| 73ada5a221 | |||
| 2f96256893 | |||
| 23d9e0775f | |||
| 72ade39144 | |||
| ec64c68777 | |||
| 80932e069f | |||
| 2f9b154b07 | |||
| 20bf911732 | |||
| 65a3dbb228 | |||
| 5844cc93ca | |||
| 4d23ce58c4 | |||
| 2bb592d5f6 | |||
| 3146b20c15 | |||
| 455cf67750 | |||
| a6d6a877b0 | |||
| a7bd54471c | |||
| fe5f803163 | |||
| 66a9b5362a | |||
| f3569cf68b | |||
| 2573f14726 | |||
| f1fb2d6abf | |||
| 4934e0ff0a | |||
| f772a80501 | |||
| 8950843be2 | |||
| 9b89e68908 | |||
| ba134ca53f | |||
| 21dbd9c057 | |||
| 40a68f8e05 | |||
| 37d861a631 | |||
| 31f3e885ce | |||
| 7ffaab2012 | |||
| 35b7946b0d | |||
| 3a05a8e712 | |||
| 294a1149ef | |||
| 8d80370014 | |||
| 1cbdef36cf | |||
| 4c8accbfc1 | |||
| c4c2d9cb93 | |||
| 7aed112326 | |||
| 216a3d53cd | |||
| e0823b343b | |||
| cb0bc65ee4 | |||
| 5b9ab6636f | |||
| 9fd77feebb |
@@ -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
+92
-98
@@ -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",
|
||||
@@ -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",
|
||||
@@ -405,10 +406,11 @@ dependencies = [
|
||||
|
||||
[[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",
|
||||
@@ -429,10 +431,11 @@ dependencies = [
|
||||
|
||||
[[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",
|
||||
@@ -453,10 +456,11 @@ dependencies = [
|
||||
|
||||
[[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",
|
||||
@@ -478,9 +482,9 @@ dependencies = [
|
||||
|
||||
[[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",
|
||||
@@ -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",
|
||||
@@ -556,7 +560,7 @@ dependencies = [
|
||||
"http 1.4.1",
|
||||
"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",
|
||||
@@ -629,9 +633,9 @@ 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",
|
||||
@@ -669,9 +673,9 @@ dependencies = [
|
||||
|
||||
[[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",
|
||||
@@ -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.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -1056,14 +1060,14 @@ checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.62"
|
||||
version = "1.2.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
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",
|
||||
@@ -2333,7 +2337,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"hyper 1.10.0",
|
||||
"hyper 1.10.1",
|
||||
"jsonwebtoken",
|
||||
"once_cell",
|
||||
"prost",
|
||||
@@ -2766,9 +2770,9 @@ 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",
|
||||
@@ -2808,7 +2812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http 1.4.1",
|
||||
"hyper 1.10.0",
|
||||
"hyper 1.10.1",
|
||||
"hyper-util",
|
||||
"rustls 0.23.40",
|
||||
"rustls-native-certs",
|
||||
@@ -2823,7 +2827,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 +2842,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper 1.10.0",
|
||||
"hyper 1.10.1",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
@@ -2858,12 +2862,12 @@ dependencies = [
|
||||
"futures-util",
|
||||
"http 1.4.1",
|
||||
"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 +3157,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 +3170,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",
|
||||
@@ -3351,9 +3355,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",
|
||||
]
|
||||
@@ -3511,9 +3515,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",
|
||||
@@ -4412,7 +4416,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 +4454,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.6.4",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -4668,7 +4672,7 @@ dependencies = [
|
||||
"http 1.4.1",
|
||||
"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",
|
||||
@@ -4714,7 +4718,7 @@ dependencies = [
|
||||
"http 1.4.1",
|
||||
"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",
|
||||
@@ -4817,9 +4821,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 +4948,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 +5038,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 +5116,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"
|
||||
@@ -5320,9 +5309,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 +5329,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 +5354,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 +5448,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"
|
||||
@@ -5582,9 +5576,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 +6013,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",
|
||||
]
|
||||
@@ -6130,13 +6124,13 @@ dependencies = [
|
||||
"http 1.4.1",
|
||||
"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",
|
||||
@@ -6311,9 +6305,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 +6380,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"
|
||||
@@ -6523,9 +6517,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
version = "1.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -7418,9 +7412,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 +7435,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.49"
|
||||
version = "0.8.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b"
|
||||
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.49"
|
||||
version = "0.8.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e"
|
||||
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+1
-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"
|
||||
|
||||
@@ -14,6 +14,21 @@ review_attempts=$(echo "$state" | jq -r '.review_attempts // 0')
|
||||
max_review_attempts=$(echo "$state" | jq -r '.max_review_attempts // 1')
|
||||
review_notes=$(echo "$state" | jq -r '.review_notes // ""')
|
||||
|
||||
if [[ "$review_clean" != "true" && "$review_clean" != "false" ]]; then
|
||||
echo "ERROR: review_clean must be boolean ('true'/'false'); got: $review_clean" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$review_attempts" =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: review_attempts must be a non-negative integer; got: $review_attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$max_review_attempts" =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: max_review_attempts must be a non-negative integer; got: $max_review_attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$review_clean" == "true" ]]; then
|
||||
jq -nc '{"_next": "end_success"}'
|
||||
exit 0
|
||||
|
||||
@@ -28,6 +28,8 @@ main() {
|
||||
local grep_args=(-nH --color=never)
|
||||
|
||||
if [[ -d "$search_path" ]]; then
|
||||
# Use -r (not -R) so symlinks to directories are NOT followed - this avoids
|
||||
# infinite loops on pathological symlink cycles (e.g. `ln -s . loop`).
|
||||
grep_args+=(-r)
|
||||
grep_args+=(
|
||||
--exclude-dir='.git'
|
||||
|
||||
@@ -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
|
||||
@@ -657,7 +659,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,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.
|
||||
- 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)
|
||||
|
||||
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.
|
||||
|
||||
+15
-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.
|
||||
|
||||
@@ -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)
|
||||
- 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)
|
||||
|
||||
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).
|
||||
|
||||
+105
@@ -3,6 +3,62 @@
|
||||
# - https://platform.openai.com/docs/api-reference/chat
|
||||
- provider: openai
|
||||
models:
|
||||
- name: gpt-5.5
|
||||
max_input_tokens: 1050000
|
||||
max_output_tokens: 128000
|
||||
input_price: 5
|
||||
output_price: 30
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: gpt-5.5-pro
|
||||
max_input_tokens: 1050000
|
||||
max_output_tokens: 128000
|
||||
input_price: 30
|
||||
output_price: 180
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: gpt-5.4
|
||||
max_input_tokens: 1050000
|
||||
max_output_tokens: 128000
|
||||
input_price: 2.5
|
||||
output_price: 15
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: gpt-5.4-pro
|
||||
max_input_tokens: 1050000
|
||||
max_output_tokens: 128000
|
||||
input_price: 30
|
||||
output_price: 180
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: gpt-5.4-mini
|
||||
max_input_tokens: 400000
|
||||
max_output_tokens: 128000
|
||||
input_price: 0.75
|
||||
output_price: 4.5
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: gpt-5.4-nano
|
||||
max_input_tokens: 400000
|
||||
max_output_tokens: 128000
|
||||
input_price: 0.2
|
||||
output_price: 1.25
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: gpt-5.3-codex
|
||||
max_input_tokens: 400000
|
||||
max_output_tokens: 128000
|
||||
input_price: 1.75
|
||||
output_price: 14
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: chat-latest
|
||||
max_input_tokens: 400000
|
||||
max_output_tokens: 128000
|
||||
input_price: 5
|
||||
output_price: 30
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: gpt-5.2
|
||||
max_input_tokens: 400000
|
||||
max_output_tokens: 128000
|
||||
@@ -1540,6 +1596,55 @@
|
||||
# - https://openrouter.ai/docs/api-reference/chat-completion
|
||||
- provider: openrouter
|
||||
models:
|
||||
- name: openai/gpt-5.5
|
||||
max_input_tokens: 1050000
|
||||
max_output_tokens: 128000
|
||||
input_price: 5
|
||||
output_price: 30
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: openai/gpt-5.5-pro
|
||||
max_input_tokens: 1050000
|
||||
max_output_tokens: 128000
|
||||
input_price: 30
|
||||
output_price: 180
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: openai/gpt-5.4
|
||||
max_input_tokens: 1050000
|
||||
max_output_tokens: 128000
|
||||
input_price: 2.5
|
||||
output_price: 15
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: openai/gpt-5.4-pro
|
||||
max_input_tokens: 1050000
|
||||
max_output_tokens: 128000
|
||||
input_price: 30
|
||||
output_price: 180
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: openai/gpt-5.4-mini
|
||||
max_input_tokens: 400000
|
||||
max_output_tokens: 128000
|
||||
input_price: 0.75
|
||||
output_price: 4.5
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: openai/gpt-5.4-nano
|
||||
max_input_tokens: 400000
|
||||
max_output_tokens: 128000
|
||||
input_price: 0.2
|
||||
output_price: 1.25
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: openai/gpt-5.3-codex
|
||||
max_input_tokens: 400000
|
||||
max_output_tokens: 128000
|
||||
input_price: 1.75
|
||||
output_price: 14
|
||||
supports_vision: true
|
||||
supports_function_calling: true
|
||||
- name: openai/gpt-5.2
|
||||
max_input_tokens: 400000
|
||||
max_output_tokens: 128000
|
||||
|
||||
+10
-7
@@ -137,13 +137,16 @@ pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||
pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||
let cur = current.to_string_lossy();
|
||||
match load_app_config_for_completion() {
|
||||
Ok(app_config) => Vault::init(&app_config)
|
||||
.list_secrets(false)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|s| s.starts_with(&*cur))
|
||||
.map(CompletionCandidate::new)
|
||||
.collect(),
|
||||
Ok(app_config) => match Vault::init(&app_config) {
|
||||
Ok(vault) => vault
|
||||
.list_secrets(false)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|s| s.starts_with(&*cur))
|
||||
.map(CompletionCandidate::new)
|
||||
.collect(),
|
||||
Err(_) => vec![],
|
||||
},
|
||||
Err(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use clap::ValueHint;
|
||||
use clap::{Parser, crate_authors, crate_description, crate_version};
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use is_terminal::IsTerminal;
|
||||
use std::collections::HashSet;
|
||||
use std::io::{Read, stdin};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -163,6 +164,18 @@ pub struct Cli {
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
pub fn skills(&self) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::with_capacity(self.skill.len());
|
||||
for name in &self.skill {
|
||||
if seen.insert(name.clone()) {
|
||||
out.push(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
pub fn text(&self) -> Result<Option<String>> {
|
||||
let mut stdin_text = String::new();
|
||||
if !stdin().is_terminal() {
|
||||
@@ -323,6 +336,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skills_method_dedupes_preserving_first_occurrence() {
|
||||
let cli = parse(&[
|
||||
"--skill", "alpha", "--skill", "beta", "--skill", "alpha", "--skill", "gamma",
|
||||
"--skill", "beta",
|
||||
]);
|
||||
|
||||
assert_eq!(cli.skills(), vec!["alpha", "beta", "gamma"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skills_method_returns_empty_when_no_flags() {
|
||||
assert!(parse(&[]).skills().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_file_flag_single() {
|
||||
let cli = parse(&["-f", "file.txt", "question"]);
|
||||
|
||||
@@ -354,7 +354,9 @@ pub async fn create_config(
|
||||
"type": client,
|
||||
});
|
||||
for (key, desc, help_message, is_secret) in prompts {
|
||||
let env_name = format!("{client}_{key}").to_ascii_uppercase();
|
||||
let env_name = format!("{client}-{key}")
|
||||
.to_ascii_uppercase()
|
||||
.replace("_", "-");
|
||||
let required = std::env::var(&env_name).is_err();
|
||||
let value = if !is_secret {
|
||||
prompt_input_string(desc, required, *help_message)?
|
||||
|
||||
@@ -464,6 +464,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 +633,10 @@ 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 compression_threshold: Option<usize>,
|
||||
#[serde(default)]
|
||||
@@ -704,6 +716,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(),
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::render::{MarkdownRender, RenderOptions};
|
||||
use crate::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, get_env_name};
|
||||
|
||||
use super::paths;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use gman::providers::SupportedProvider;
|
||||
use indexmap::IndexMap;
|
||||
use serde::Deserialize;
|
||||
@@ -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>,
|
||||
@@ -118,6 +120,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,
|
||||
@@ -185,6 +189,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,
|
||||
@@ -216,6 +222,7 @@ impl AppConfig {
|
||||
clients: config.clients,
|
||||
};
|
||||
app_config.load_envs();
|
||||
app_config.validate_visible_skills()?;
|
||||
if let Some(wrap) = app_config.wrap.clone() {
|
||||
app_config.set_wrap(&wrap)?;
|
||||
}
|
||||
@@ -225,11 +232,28 @@ impl AppConfig {
|
||||
Ok(app_config)
|
||||
}
|
||||
|
||||
fn validate_visible_skills(&self) -> Result<()> {
|
||||
let Some(skills) = self.visible_skills.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
for name in skills {
|
||||
paths::validate_skill_name(name)
|
||||
.map_err(|e| anyhow!("invalid entry in visible_skills: {e}"))?;
|
||||
|
||||
if !paths::has_skill(name) {
|
||||
bail!("visible_skills references skill '{name}' which is not installed");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resolve_model(&mut self) -> Result<()> {
|
||||
if self.model_id.is_empty() {
|
||||
let models = list_models(self, crate::client::ModelType::Chat);
|
||||
if models.is_empty() {
|
||||
anyhow::bail!("No available model");
|
||||
bail!("No available model");
|
||||
}
|
||||
self.model_id = models[0].id();
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ impl AppState {
|
||||
start_mcp_servers: bool,
|
||||
abort_signal: AbortSignal,
|
||||
) -> Result<Self> {
|
||||
let vault = Arc::new(Vault::init(&config));
|
||||
let vault = Arc::new(Vault::init(&config)?);
|
||||
|
||||
let mcp_registry = McpRegistry::init(
|
||||
log_path,
|
||||
|
||||
+107
-10
@@ -1,10 +1,3 @@
|
||||
use anyhow::{Context, Result, bail};
|
||||
use indexmap::IndexMap;
|
||||
use inquire::{Confirm, Select};
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::{InstallFilter, paths};
|
||||
#[cfg(not(windows))]
|
||||
use crate::function::Language;
|
||||
@@ -12,6 +5,13 @@ use crate::mcp::{McpServer, McpServersConfig};
|
||||
use crate::utils;
|
||||
use crate::utils::IS_STDOUT_TERMINAL;
|
||||
use crate::vault::{Vault, create_vault_password_file, interpolate_secrets};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use indexmap::IndexMap;
|
||||
use indoc::formatdoc;
|
||||
use inquire::{Confirm, Select};
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool) -> Result<()> {
|
||||
let (url, reference) = parse_url_with_ref(git_url)?;
|
||||
@@ -418,6 +418,26 @@ fn plan_dir_into(
|
||||
let rel = src
|
||||
.strip_prefix(src_dir)
|
||||
.expect("walk_files only returns paths under src_dir");
|
||||
|
||||
if category == TopCategory::Skills {
|
||||
let skill_name = rel
|
||||
.components()
|
||||
.next()
|
||||
.and_then(|c| c.as_os_str().to_str())
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"remote skill bundle has unparseable path component: {}",
|
||||
rel.display()
|
||||
)
|
||||
})?;
|
||||
paths::validate_skill_name(skill_name).with_context(|| {
|
||||
format!(
|
||||
"remote skill '{skill_name}' has an invalid name \
|
||||
(skill names must contain only ASCII alphanumerics, '-', or '_')"
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let dst = dst_dir.join(rel);
|
||||
let kind = classify_file(&src, &dst)?;
|
||||
out.push(PlannedFile {
|
||||
@@ -731,8 +751,21 @@ fn merge_mcp_json(
|
||||
serde_json::to_string_pretty(&merged).context("failed to serialize merged mcp.json")?;
|
||||
write_atomically(&final_path, &serialized)?;
|
||||
|
||||
let vault = Vault::init_bare();
|
||||
let (_parsed, missing) = interpolate_secrets(&serialized, &vault)?;
|
||||
let vault = Vault::init_bare()?;
|
||||
let missing = match interpolate_secrets(&serialized, &vault) {
|
||||
Ok((_, missing)) => missing,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
formatdoc! {"
|
||||
Skipping secret resolution for merged mcp.json: {e:#}
|
||||
Continuing without resolving missing secrets
|
||||
You may need to add any additional missing secrets to the vault manually.
|
||||
"}
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let mut deduped: Vec<String> = Vec::new();
|
||||
for s in missing {
|
||||
if !deduped.contains(&s) {
|
||||
@@ -860,7 +893,7 @@ fn handle_missing_secrets(missing: &[String]) -> Result<()> {
|
||||
}
|
||||
|
||||
fn prompt_for_each_secret(missing: &[String]) -> Result<(Vec<String>, Vec<String>)> {
|
||||
let mut vault = Vault::init_bare();
|
||||
let mut vault = Vault::init_bare()?;
|
||||
let mut password_file_ensured = false;
|
||||
let mut added = Vec::new();
|
||||
let mut deferred = Vec::new();
|
||||
@@ -914,6 +947,62 @@ fn print_secret_summary(added: &[String], deferred: &[String]) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::get_env_name;
|
||||
use serial_test::serial;
|
||||
use std::env;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct TestVaultConfigGuard {
|
||||
dir_key: String,
|
||||
file_key: String,
|
||||
previous_dir: Option<OsString>,
|
||||
previous_file: Option<OsString>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestVaultConfigGuard {
|
||||
fn new(label: &str) -> Self {
|
||||
let dir_key = get_env_name("config_dir");
|
||||
let file_key = get_env_name("config_file");
|
||||
let previous_dir = env::var_os(&dir_key);
|
||||
let previous_file = env::var_os(&file_key);
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = env::temp_dir().join(format!("coyote-vault-test-{label}-{unique}"));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
let config_path = path.join("config.yaml");
|
||||
fs::write(&config_path, "{}").unwrap();
|
||||
unsafe {
|
||||
env::set_var(&dir_key, &path);
|
||||
env::set_var(&file_key, &config_path);
|
||||
}
|
||||
Self {
|
||||
dir_key,
|
||||
file_key,
|
||||
previous_dir,
|
||||
previous_file,
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestVaultConfigGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
match &self.previous_dir {
|
||||
Some(p) => env::set_var(&self.dir_key, p),
|
||||
None => env::remove_var(&self.dir_key),
|
||||
}
|
||||
match &self.previous_file {
|
||||
Some(p) => env::set_var(&self.file_key, p),
|
||||
None => env::remove_var(&self.file_key),
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_no_ref() {
|
||||
@@ -1253,7 +1342,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn merge_into_empty_local_adds_all_remote_servers() {
|
||||
let _guard = TestVaultConfigGuard::new("merge-empty");
|
||||
let dir = fresh_temp_dir("merge-empty-");
|
||||
let remote = dir.join("remote.json");
|
||||
let target = dir.join("target.json");
|
||||
@@ -1270,7 +1361,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn merge_force_replaces_local_on_conflict() {
|
||||
let _guard = TestVaultConfigGuard::new("merge-force");
|
||||
let dir = fresh_temp_dir("merge-force-");
|
||||
let remote = dir.join("remote.json");
|
||||
let target = dir.join("target.json");
|
||||
@@ -1336,7 +1429,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
async fn merge_detects_missing_secrets_in_output() {
|
||||
let _guard = TestVaultConfigGuard::new("merge-secret");
|
||||
let dir = fresh_temp_dir("merge-secret-");
|
||||
let remote = dir.join("remote.json");
|
||||
let target = dir.join("target.json");
|
||||
@@ -1352,7 +1447,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn merge_is_idempotent_on_re_run() {
|
||||
let _guard = TestVaultConfigGuard::new("merge-idempotent");
|
||||
let dir = fresh_temp_dir("merge-idempotent-");
|
||||
let remote = dir.join("remote.json");
|
||||
let target = dir.join("target.json");
|
||||
|
||||
+8
-4
@@ -6,7 +6,7 @@ mod install_remote;
|
||||
mod macros;
|
||||
mod mcp_factory;
|
||||
pub(crate) mod paths;
|
||||
mod prompts;
|
||||
pub(crate) mod prompts;
|
||||
mod rag_cache;
|
||||
mod request_context;
|
||||
mod role;
|
||||
@@ -28,7 +28,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,
|
||||
};
|
||||
@@ -214,6 +214,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>,
|
||||
@@ -280,6 +282,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,
|
||||
@@ -490,7 +494,7 @@ impl Config {
|
||||
secrets_provider: config.secrets_provider.clone(),
|
||||
..AppConfig::default()
|
||||
};
|
||||
let vault = Vault::init(&bootstrap_app);
|
||||
let vault = Vault::init(&bootstrap_app)?;
|
||||
let (parsed_config, missing_secrets) = interpolate_secrets(&content, &vault)?;
|
||||
if !missing_secrets.is_empty() && !info_flag {
|
||||
debug!(
|
||||
@@ -685,7 +689,7 @@ pub async fn create_config_file(config_path: &Path) -> Result<()> {
|
||||
|
||||
let provider_choice = prompt_provider_choice()?;
|
||||
let mut vault = match &provider_choice {
|
||||
None => Vault::init_bare(),
|
||||
None => Vault::default_local(),
|
||||
Some(provider) => Vault {
|
||||
provider: provider.clone(),
|
||||
},
|
||||
|
||||
+37
-8
@@ -270,6 +270,7 @@ pub fn list_skills() -> Vec<String> {
|
||||
&& file_type.is_dir()
|
||||
&& let Some(name) = entry.file_name().to_str()
|
||||
&& entry.path().join("SKILL.md").is_file()
|
||||
&& validate_skill_name(name).is_ok()
|
||||
{
|
||||
names.push(name.to_string());
|
||||
}
|
||||
@@ -281,10 +282,6 @@ pub fn list_skills() -> Vec<String> {
|
||||
}
|
||||
|
||||
pub fn has_skill(name: &str) -> bool {
|
||||
if validate_skill_name(name).is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
skill_file(name).is_file()
|
||||
}
|
||||
|
||||
@@ -307,6 +304,7 @@ pub fn local_models_override() -> Result<Vec<ProviderModels>> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{fs, time};
|
||||
|
||||
#[test]
|
||||
fn validate_skill_name_accepts_alphanumerics_and_dashes() {
|
||||
@@ -343,12 +341,43 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_skill_returns_false_for_invalid_names() {
|
||||
for bad in ["", "../escape", "foo/bar", ".hidden", "with space"] {
|
||||
fn has_skill_returns_false_for_missing_paths() {
|
||||
for absent in ["definitely-not-installed-skill-xyz", "another-missing"] {
|
||||
assert!(
|
||||
!has_skill(bad),
|
||||
"has_skill({bad:?}) should be false for an invalid name"
|
||||
!has_skill(absent),
|
||||
"has_skill({absent:?}) should be false for a missing skill"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_skills_skips_invalid_directory_names() {
|
||||
let unique = time::SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let root = env::temp_dir().join(format!("coyote-list-skills-test-{unique}"));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let prev = env::var_os(get_env_name("skills_dir"));
|
||||
unsafe {
|
||||
env::set_var(get_env_name("skills_dir"), &root);
|
||||
}
|
||||
|
||||
for name in ["valid-skill", "with space", ".hidden", "dot.name"] {
|
||||
let dir = root.join(name);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("SKILL.md"), "body").unwrap();
|
||||
}
|
||||
|
||||
let listed = list_skills();
|
||||
assert_eq!(listed, vec!["valid-skill".to_string()]);
|
||||
|
||||
unsafe {
|
||||
match prev {
|
||||
Some(v) => env::set_var(get_env_name("skills_dir"), v),
|
||||
None => env::remove_var(get_env_name("skills_dir")),
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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! {"
|
||||
## Task Tracking
|
||||
You have built-in task tracking tools. Use them to track your progress:
|
||||
|
||||
@@ -39,6 +39,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 +54,20 @@ pub struct AutoContinueConfig {
|
||||
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)]
|
||||
pub enum RenderMode {
|
||||
#[default]
|
||||
@@ -634,9 +649,62 @@ 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if let Some(agent) = &self.agent {
|
||||
return AutoContinueConfig {
|
||||
@@ -1207,7 +1275,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()
|
||||
@@ -1231,7 +1300,8 @@ impl RequestContext {
|
||||
if let Some(ref tool_names) = role_filter {
|
||||
agent_functions.retain(|v| {
|
||||
tool_names.contains(&v.name)
|
||||
|| v.name.starts_with(SKILL_FUNCTION_PREFIX)
|
||||
|| (!matches!(agent.skills_enabled(), Some(false))
|
||||
&& v.name.starts_with(SKILL_FUNCTION_PREFIX))
|
||||
|| v.name.starts_with(USER_FUNCTION_PREFIX)
|
||||
});
|
||||
}
|
||||
@@ -1706,7 +1776,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.");
|
||||
@@ -1737,6 +1807,26 @@ impl RequestContext {
|
||||
"enabled_skills" => {
|
||||
let raw: Option<String> = super::parse_value(value)?;
|
||||
let parsed: Option<Vec<String>> = raw.map(|s| super::csv_to_vec(&s));
|
||||
if let Some(names) = parsed.as_ref() {
|
||||
let visible = self.app.config.visible_skills.as_deref();
|
||||
for name in names {
|
||||
paths::validate_skill_name(name)?;
|
||||
match visible {
|
||||
Some(vs) => {
|
||||
if !vs.iter().any(|s| s == name) {
|
||||
bail!(
|
||||
"skill '{name}' is not in the global 'visible_skills' allow-list"
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if !paths::has_skill(name) {
|
||||
bail!("skill '{name}' is not installed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.update_app_config(|app| app.enabled_skills = parsed.clone());
|
||||
}
|
||||
"skills_enabled" => {
|
||||
@@ -1886,6 +1976,22 @@ 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);
|
||||
}
|
||||
}
|
||||
_ => bail!("Unknown key '{key}'"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -1985,6 +2091,8 @@ impl RequestContext {
|
||||
"enabled_tools",
|
||||
"enabled_mcp_servers",
|
||||
"inject_todo_instructions",
|
||||
"inject_skill_instructions",
|
||||
"skill_instructions",
|
||||
"max_auto_continues",
|
||||
"save_session",
|
||||
"compression_threshold",
|
||||
@@ -2151,6 +2259,11 @@ 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()],
|
||||
_ => vec![],
|
||||
};
|
||||
values = candidates.into_iter().map(|v| (v, None)).collect();
|
||||
@@ -2650,7 +2763,9 @@ impl RequestContext {
|
||||
|
||||
self.skill_registry.insert(skill)?;
|
||||
if let Err(e) = self.refresh_tool_scope(abort_signal).await {
|
||||
let _ = self.skill_registry.unload(name);
|
||||
if let Err(unload_err) = self.skill_registry.unload(name) {
|
||||
warn!("Failed to unload skill '{name}' during error recovery: {unload_err}");
|
||||
}
|
||||
bail!("Loaded skill '{name}' but failed to refresh tool scope: {e}");
|
||||
}
|
||||
|
||||
@@ -2659,10 +2774,15 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
pub async fn unload_skill_repl(&mut self, name: &str, abort_signal: AbortSignal) -> Result<()> {
|
||||
self.skill_registry.unload(name)?;
|
||||
let skill = self.skill_registry.unload(name)?;
|
||||
|
||||
if let Err(e) = self.refresh_tool_scope(abort_signal).await {
|
||||
eprintln!("Warning: unloaded skill '{name}' but tool scope refresh failed: {e}");
|
||||
if let Err(restore_err) = self.skill_registry.insert(skill) {
|
||||
warn!(
|
||||
"Failed to restore skill '{name}' after tool-scope refresh failure: {restore_err}"
|
||||
);
|
||||
}
|
||||
bail!("Unloaded skill '{name}' but failed to refresh tool scope; restored: {e}");
|
||||
}
|
||||
|
||||
println!("✓ Unloaded skill '{name}'.");
|
||||
@@ -2953,11 +3073,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};
|
||||
@@ -3095,6 +3216,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();
|
||||
@@ -3420,6 +3643,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 = {
|
||||
@@ -3649,8 +4048,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,10 @@ 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)]
|
||||
model: Model,
|
||||
@@ -124,6 +128,10 @@ 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())
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
@@ -189,6 +197,14 @@ 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 metadata.is_empty() {
|
||||
format!("{}\n", self.prompt)
|
||||
} else if self.prompt.is_empty() {
|
||||
@@ -299,6 +315,14 @@ 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 skills_enabled(&self) -> Option<bool> {
|
||||
self.skills_enabled
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ 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")]
|
||||
role_name: Option<String>,
|
||||
@@ -227,6 +231,12 @@ 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();
|
||||
}
|
||||
let (tokens, percent) = self.tokens_usage();
|
||||
data["total_tokens"] = tokens.into();
|
||||
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() {
|
||||
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() {
|
||||
items.push(("max_input_tokens", max_input_tokens.to_string()));
|
||||
@@ -446,6 +465,14 @@ 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 set_inject_todo_instructions(&mut self, value: Option<bool>) {
|
||||
if 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 {
|
||||
if self.compressing {
|
||||
return false;
|
||||
|
||||
+274
-41
@@ -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, bail};
|
||||
use std::collections::HashSet;
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
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
|
||||
@@ -76,16 +85,24 @@ impl SkillPolicy {
|
||||
Some(explicit) => {
|
||||
let set: HashSet<String> = explicit.into_iter().collect();
|
||||
for name in &set {
|
||||
if !skill_exists(name) {
|
||||
bail!("enabled_skills references skill '{name}' which is not installed");
|
||||
}
|
||||
|
||||
if let Some(vs) = &visible
|
||||
&& !vs.contains(name)
|
||||
{
|
||||
bail!(
|
||||
"enabled_skills references skill '{name}' which is not in visible_skills"
|
||||
);
|
||||
paths::validate_skill_name(name).map_err(|e| {
|
||||
anyhow!("enabled_skills contains invalid name '{name}': {e}")
|
||||
})?;
|
||||
match &visible {
|
||||
Some(vs) => {
|
||||
if !vs.contains(name) {
|
||||
bail!(
|
||||
"enabled_skills references skill '{name}' which is not in the global 'visible_skills' allow-list"
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if !skill_exists(name) {
|
||||
bail!(
|
||||
"enabled_skills references skill '{name}' which is not installed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
set
|
||||
@@ -96,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,6 +149,10 @@ mod tests {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn all_compatible(_: &str, _: bool) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn make_app_config(
|
||||
skills_enabled: bool,
|
||||
enabled: Option<&str>,
|
||||
@@ -137,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());
|
||||
@@ -150,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"));
|
||||
@@ -163,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"));
|
||||
@@ -176,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"));
|
||||
@@ -197,6 +258,7 @@ mod tests {
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -216,6 +278,7 @@ mod tests {
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -229,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"));
|
||||
@@ -241,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"));
|
||||
@@ -253,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"));
|
||||
@@ -265,11 +348,21 @@ 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().contains("not in visible_skills"));
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("not in the global 'visible_skills'")
|
||||
);
|
||||
assert!(err.to_string().contains("beta"));
|
||||
}
|
||||
|
||||
@@ -277,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());
|
||||
}
|
||||
@@ -296,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)
|
||||
}
|
||||
|
||||
+27
-17
@@ -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,11 +105,14 @@ 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(),
|
||||
};
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for name in paths::list_skills() {
|
||||
if !policy.allows(&name) {
|
||||
for name in visible_names {
|
||||
if !policy.compatible_enabled.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -117,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(),
|
||||
@@ -193,7 +193,10 @@ async fn handle_load(
|
||||
}
|
||||
|
||||
if let Err(e) = ctx.refresh_tool_scope(create_abort_signal()).await {
|
||||
let _ = ctx.skill_registry.unload(name);
|
||||
if let Err(unload_err) = ctx.skill_registry.unload(name) {
|
||||
warn!("Failed to unload skill '{name}' during error recovery: {unload_err}");
|
||||
}
|
||||
|
||||
return Ok(json!({
|
||||
"error": format!("Loaded skill '{name}' but failed to refresh tool scope: {e}")
|
||||
}));
|
||||
@@ -212,13 +215,20 @@ async fn handle_unload(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
||||
_ => return Ok(json!({"error": "name is required"})),
|
||||
};
|
||||
|
||||
if let Err(e) = paths::validate_skill_name(name) {
|
||||
return Ok(json!({"error": e.to_string()}));
|
||||
}
|
||||
|
||||
let skill = match ctx.skill_registry.unload(name) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Ok(json!({"error": e.to_string()})),
|
||||
};
|
||||
|
||||
if let Err(e) = ctx.refresh_tool_scope(create_abort_signal()).await {
|
||||
let _ = ctx.skill_registry.insert(skill);
|
||||
if let Err(insert_err) = ctx.skill_registry.insert(skill) {
|
||||
warn!("Failed to restore skill '{name}' after unload recovery: {insert_err}");
|
||||
}
|
||||
|
||||
return Ok(json!({
|
||||
"error": format!(
|
||||
"Unloaded skill '{name}' but failed to refresh tool scope; restored: {e}"
|
||||
|
||||
+40
-10
@@ -2,7 +2,10 @@ 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::utils::create_abort_signal;
|
||||
use anyhow::{Context, Error, Result, anyhow, bail};
|
||||
@@ -116,20 +119,20 @@ async fn run(
|
||||
|
||||
let saved_agent_skill_state = swap_in_node_skill_policy(node, parent_ctx);
|
||||
|
||||
let policy = SkillPolicy::effective(
|
||||
let policy = match SkillPolicy::effective(
|
||||
&parent_ctx.app.config,
|
||||
parent_ctx.role.as_ref(),
|
||||
parent_ctx.agent.as_ref(),
|
||||
parent_ctx.session.as_ref(),
|
||||
)?;
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
restore_agent_skill_policy(parent_ctx, saved_agent_skill_state);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
if policy.skills_enabled
|
||||
&& node
|
||||
.tools
|
||||
.as_deref()
|
||||
.map(|t| !t.is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if policy.skills_enabled {
|
||||
let mut tools = role.enabled_tools().map(|v| v.to_vec()).unwrap_or_default();
|
||||
for decl in skill_function_declarations() {
|
||||
if !tools.contains(&decl.name) {
|
||||
@@ -139,6 +142,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();
|
||||
@@ -456,6 +484,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 {
|
||||
|
||||
+72
-1
@@ -93,6 +93,7 @@ impl AgentValidationContext {
|
||||
pub struct GraphValidator {
|
||||
base_dir: PathBuf,
|
||||
agent_ctx: Option<AgentValidationContext>,
|
||||
skill_exists: fn(&str) -> bool,
|
||||
}
|
||||
|
||||
impl GraphValidator {
|
||||
@@ -100,6 +101,7 @@ impl GraphValidator {
|
||||
Self {
|
||||
base_dir: base_dir.into(),
|
||||
agent_ctx: None,
|
||||
skill_exists: paths::has_skill,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +110,12 @@ impl GraphValidator {
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn with_skill_exists(mut self, f: fn(&str) -> bool) -> Self {
|
||||
self.skill_exists = f;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn validate(&self, graph: &Graph) -> ValidationResult {
|
||||
let mut result = ValidationResult::default();
|
||||
self.validate_node_references(graph, &mut result);
|
||||
@@ -191,6 +199,49 @@ impl GraphValidator {
|
||||
}
|
||||
|
||||
fn validate_llm_skills(&self, graph: &Graph, result: &mut ValidationResult) {
|
||||
let visible_skills = self
|
||||
.agent_ctx
|
||||
.as_ref()
|
||||
.and_then(|c| c.app_config.visible_skills.as_deref());
|
||||
|
||||
let skill_exists = self.skill_exists;
|
||||
let has_agent_ctx = self.agent_ctx.is_some();
|
||||
let check_visibility = |name: &str| -> Option<String> {
|
||||
if !has_agent_ctx {
|
||||
return None;
|
||||
}
|
||||
|
||||
match visible_skills {
|
||||
Some(list) if !list.iter().any(|s| s == name) => Some(format!(
|
||||
"'{name}' is not in the global 'visible_skills' allow-list"
|
||||
)),
|
||||
None if !skill_exists(name) => Some(format!("'{name}' is not installed")),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(graph_skills) = &graph.enabled_skills {
|
||||
for name in graph_skills {
|
||||
if name.trim().is_empty() {
|
||||
result.error(ValidationError::new(
|
||||
"graph 'enabled_skills' contains an empty skill name",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = paths::validate_skill_name(name) {
|
||||
result.error(ValidationError::new(format!(
|
||||
"graph 'enabled_skills' contains an invalid skill name: '{name}': {e}"
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
if let Some(reason) = check_visibility(name) {
|
||||
result.error(ValidationError::new(format!(
|
||||
"graph 'enabled_skills': {reason}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (node_id, node) in &graph.nodes {
|
||||
let NodeType::Llm(llm) = &node.node_type else {
|
||||
continue;
|
||||
@@ -207,6 +258,22 @@ impl GraphValidator {
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = paths::validate_skill_name(name) {
|
||||
result.error(ValidationError::with_node(
|
||||
node_id,
|
||||
format!(
|
||||
"llm node 'enabled_skills' contains an invalid skill name: '{name}': {e}"
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
if let Some(reason) = check_visibility(name) {
|
||||
result.error(ValidationError::with_node(
|
||||
node_id,
|
||||
format!("llm node 'enabled_skills': {reason}"),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(graph_skills) = &graph.enabled_skills
|
||||
&& !graph_skills.iter().any(|g| g == name)
|
||||
{
|
||||
@@ -883,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(),
|
||||
@@ -984,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),
|
||||
}
|
||||
@@ -1325,7 +1396,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn validator() -> GraphValidator {
|
||||
GraphValidator::new(env::current_dir().unwrap())
|
||||
GraphValidator::new(env::current_dir().unwrap()).with_skill_exists(|_: &str| true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+14
-10
@@ -113,7 +113,7 @@ async fn main() -> Result<()> {
|
||||
if vault_flags {
|
||||
let cfg = Config::load_with_interpolation(true).await?;
|
||||
let app_config = AppConfig::from_config(cfg)?;
|
||||
let vault = Vault::init(&app_config);
|
||||
let vault = Vault::init(&app_config)?;
|
||||
return Vault::handle_vault_flags(cli, &vault);
|
||||
}
|
||||
|
||||
@@ -197,14 +197,18 @@ async fn run(
|
||||
println!("{skills}");
|
||||
return Ok(());
|
||||
}
|
||||
if cli.skill.len() == 1 && !paths::has_skill(&cli.skill[0]) {
|
||||
let name = &cli.skill[0];
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
ctx.upsert_skill(app.as_ref(), name)?;
|
||||
return Ok(());
|
||||
}
|
||||
if cli.skill.len() > 1 {
|
||||
for name in &cli.skill {
|
||||
let skills = cli.skills();
|
||||
if skills.len() == 1 {
|
||||
let name = &skills[0];
|
||||
paths::validate_skill_name(name)?;
|
||||
if !paths::has_skill(name) {
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
ctx.upsert_skill(app.as_ref(), name)?;
|
||||
return Ok(());
|
||||
}
|
||||
} else if skills.len() > 1 {
|
||||
for name in &skills {
|
||||
paths::validate_skill_name(name)?;
|
||||
if !paths::has_skill(name) {
|
||||
bail!("Skill '{name}' is not installed");
|
||||
}
|
||||
@@ -323,7 +327,7 @@ async fn run(
|
||||
.await?;
|
||||
}
|
||||
|
||||
for name in &cli.skill {
|
||||
for name in &cli.skills() {
|
||||
ctx.load_skill_repl(name, abort_signal.clone()).await?;
|
||||
}
|
||||
|
||||
|
||||
@@ -708,6 +708,8 @@ pub async fn run_repl_command(
|
||||
let name = s.strip_prefix("skill").unwrap_or("").trim();
|
||||
if name.is_empty() {
|
||||
println!("Usage: .edit skill <name>");
|
||||
} else if let Err(e) = paths::validate_skill_name(name) {
|
||||
bail!(e);
|
||||
} else if !paths::has_skill(name) {
|
||||
bail!(
|
||||
"Skill '{name}' is not installed (expected at {})",
|
||||
|
||||
+52
-14
@@ -1,6 +1,9 @@
|
||||
mod utils;
|
||||
|
||||
use std::fs::read_to_string;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::paths;
|
||||
pub use utils::create_vault_password_file;
|
||||
pub use utils::interpolate_secrets;
|
||||
pub use utils::prompt_provider_choice;
|
||||
@@ -14,11 +17,13 @@ use gman::providers::SecretProvider;
|
||||
use gman::providers::SupportedProvider;
|
||||
use gman::providers::local::LocalProvider;
|
||||
use inquire::{Password, PasswordDisplayMode, required};
|
||||
use log::warn;
|
||||
use serde_yaml::Value;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tokio::runtime::Handle;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub static SECRET_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{(.+)}}").unwrap());
|
||||
pub static SECRET_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{([^{}]+)}}").unwrap());
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Vault {
|
||||
@@ -28,22 +33,54 @@ pub struct Vault {
|
||||
pub type GlobalVault = Arc<Vault>;
|
||||
|
||||
impl Vault {
|
||||
pub fn init_bare() -> Self {
|
||||
let vault_password_file = AppConfig::default().vault_password_file();
|
||||
let local_provider = LocalProvider {
|
||||
password_file: Some(vault_password_file),
|
||||
git_branch: None,
|
||||
..LocalProvider::default()
|
||||
pub fn init_bare() -> Result<Self> {
|
||||
let config_path = paths::config_file();
|
||||
if !config_path.exists() {
|
||||
bail!(
|
||||
"Coyote config not found at {}. Run first-run setup before using the vault.",
|
||||
config_path.display()
|
||||
);
|
||||
}
|
||||
let content = read_to_string(&config_path)
|
||||
.with_context(|| format!("failed to read config at {}", config_path.display()))?;
|
||||
let value: Value = serde_yaml::from_str(&content)
|
||||
.with_context(|| format!("failed to parse config at {}", config_path.display()))?;
|
||||
|
||||
let provider = match value.get("secrets_provider") {
|
||||
Some(v) if !v.is_null() => serde_yaml::from_value::<SupportedProvider>(v.clone())
|
||||
.with_context(|| "failed to parse 'secrets_provider' from config")?,
|
||||
_ => {
|
||||
let password_file = value
|
||||
.get("vault_password_file")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| AppConfig::default().vault_password_file());
|
||||
SupportedProvider::Local {
|
||||
provider_def: LocalProvider {
|
||||
password_file: Some(password_file),
|
||||
git_branch: None,
|
||||
..LocalProvider::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self { provider })
|
||||
}
|
||||
|
||||
pub fn default_local() -> Self {
|
||||
Self {
|
||||
provider: SupportedProvider::Local {
|
||||
provider_def: local_provider,
|
||||
provider_def: LocalProvider {
|
||||
password_file: Some(AppConfig::default().vault_password_file()),
|
||||
git_branch: None,
|
||||
..LocalProvider::default()
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(config: &AppConfig) -> Self {
|
||||
pub fn init(config: &AppConfig) -> Result<Self> {
|
||||
let mut provider = match &config.secrets_provider {
|
||||
Some(p) => p.clone(),
|
||||
None => SupportedProvider::Local {
|
||||
@@ -55,11 +92,10 @@ impl Vault {
|
||||
};
|
||||
|
||||
if let SupportedProvider::Local { provider_def } = &mut provider {
|
||||
ensure_password_file_initialized(provider_def)
|
||||
.expect("Failed to initialize password file");
|
||||
ensure_password_file_initialized(provider_def)?;
|
||||
}
|
||||
|
||||
Self { provider }
|
||||
Ok(Self { provider })
|
||||
}
|
||||
|
||||
pub fn local_password_file(&self) -> Result<PathBuf> {
|
||||
@@ -177,7 +213,7 @@ impl Vault {
|
||||
|
||||
pub fn validate_round_trip(&self) -> Result<()> {
|
||||
const PROBE_VALUE: &str = "ok";
|
||||
let probe_key = format!("__coyote_setup_probe_{}__", Uuid::new_v4().simple());
|
||||
let probe_key = format!("coyote-setup-probe-{}", Uuid::new_v4().simple());
|
||||
|
||||
let h = Handle::current();
|
||||
let result: Result<()> = tokio::task::block_in_place(|| {
|
||||
@@ -192,7 +228,9 @@ impl Vault {
|
||||
.await
|
||||
.with_context(|| "vault read probe failed")?;
|
||||
if got != PROBE_VALUE {
|
||||
let _ = self.provider_ref().delete_secret(&probe_key).await;
|
||||
if let Err(cleanup_err) = self.provider_ref().delete_secret(&probe_key).await {
|
||||
warn!("vault probe cleanup failed for key '{probe_key}': {cleanup_err}");
|
||||
}
|
||||
bail!("vault read probe returned an unexpected value");
|
||||
}
|
||||
|
||||
|
||||
+215
-3
@@ -13,7 +13,8 @@ use gman::providers::one_password::OnePasswordProvider;
|
||||
use indoc::formatdoc;
|
||||
use inquire::validator::Validation;
|
||||
use inquire::{Confirm, Password, PasswordDisplayMode, Select, Text, min_length, required};
|
||||
use std::path::PathBuf;
|
||||
use log::debug;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
pub fn ensure_password_file_initialized(local_provider: &mut LocalProvider) -> Result<()> {
|
||||
@@ -91,6 +92,7 @@ pub fn create_vault_password_file(vault: &mut Vault) -> Result<()> {
|
||||
match password {
|
||||
Ok(pw) => {
|
||||
std::fs::write(&vault_password_file, pw.as_bytes())?;
|
||||
set_password_file_permissions(&vault_password_file)?;
|
||||
println!(
|
||||
"✓ Password file '{}' updated.",
|
||||
vault_password_file.display()
|
||||
@@ -162,6 +164,7 @@ pub fn create_vault_password_file(vault: &mut Vault) -> Result<()> {
|
||||
match password {
|
||||
Ok(pw) => {
|
||||
std::fs::write(&password_file, pw.as_bytes())?;
|
||||
set_password_file_permissions(&password_file)?;
|
||||
local_provider.password_file = Some(password_file);
|
||||
println!(
|
||||
"✓ Password file '{}' created.",
|
||||
@@ -352,6 +355,19 @@ fn required_cli_preflight(label: &str, cli: &str, install_url: &str) {
|
||||
}
|
||||
|
||||
pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec<String>)> {
|
||||
interpolate_secrets_with(content, vault.auth_hint(), |name| {
|
||||
vault.get_secret(name, false)
|
||||
})
|
||||
}
|
||||
|
||||
fn interpolate_secrets_with<F>(
|
||||
content: &str,
|
||||
auth_hint: Option<&'static str>,
|
||||
mut get_secret: F,
|
||||
) -> Result<(String, Vec<String>)>
|
||||
where
|
||||
F: FnMut(&str) -> Result<String>,
|
||||
{
|
||||
let mut missing_secrets = vec![];
|
||||
let mut fatal_error: Option<anyhow::Error> = None;
|
||||
|
||||
@@ -369,7 +385,7 @@ pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec<
|
||||
}
|
||||
|
||||
let name = caps[1].trim();
|
||||
match vault.get_secret(name, false) {
|
||||
match get_secret(name) {
|
||||
Ok(s) => s,
|
||||
Err(e) => match e.downcast_ref::<SecretError>() {
|
||||
Some(SecretError::NotFound { .. }) => {
|
||||
@@ -379,7 +395,7 @@ pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec<
|
||||
Some(SecretError::AuthFailed { .. }) => {
|
||||
let base =
|
||||
format!("Failed to fetch secret '{name}' from vault: {e}");
|
||||
let msg = match vault.auth_hint() {
|
||||
let msg = match auth_hint {
|
||||
Some(hint) => format!("{base}\n\nHint: {hint}"),
|
||||
None => base,
|
||||
};
|
||||
@@ -406,3 +422,199 @@ pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec<
|
||||
|
||||
Ok((parsed_content, missing_secrets))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_password_file_permissions(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| {
|
||||
anyhow!(
|
||||
"Failed to set 0600 permissions on '{}': {e}",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_password_file_permissions(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::Error;
|
||||
use std::cell::RefCell;
|
||||
|
||||
fn not_found(name: &str) -> Error {
|
||||
Error::new(SecretError::NotFound {
|
||||
key: name.to_string(),
|
||||
provider: "test",
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_failed() -> Error {
|
||||
Error::new(SecretError::AuthFailed {
|
||||
provider: "test",
|
||||
source: anyhow!("auth failure"),
|
||||
})
|
||||
}
|
||||
|
||||
struct Calls(RefCell<Vec<String>>);
|
||||
|
||||
impl Calls {
|
||||
fn new() -> Self {
|
||||
Self(RefCell::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn record(&self, name: &str) {
|
||||
self.0.borrow_mut().push(name.to_string());
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Vec<String> {
|
||||
self.0.borrow().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolates_single_secret_per_line() {
|
||||
let (out, missing) =
|
||||
interpolate_secrets_with("api_key={{API_KEY}}", None, |name| match name {
|
||||
"API_KEY" => Ok("sk-12345".to_string()),
|
||||
other => panic!("unexpected lookup: {other}"),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out, "api_key=sk-12345");
|
||||
assert!(missing.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_matches_each_secret_independently_when_one_per_line() {
|
||||
let calls = Calls::new();
|
||||
let (out, missing) = interpolate_secrets_with("{{ONE}}\nmiddle\n{{TWO}}", None, |name| {
|
||||
calls.record(name);
|
||||
Ok(name.to_lowercase())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(calls.snapshot(), vec!["ONE".to_string(), "TWO".to_string()]);
|
||||
assert_eq!(out, "one\nmiddle\ntwo");
|
||||
assert!(missing.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_comment_lines() {
|
||||
let calls = Calls::new();
|
||||
|
||||
let (out, missing) =
|
||||
interpolate_secrets_with("# api_key={{NEVER_FETCHED}}\nreal={{S}}", None, |name| {
|
||||
calls.record(name);
|
||||
Ok("v".to_string())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out, "# api_key={{NEVER_FETCHED}}\nreal=v");
|
||||
assert!(missing.is_empty());
|
||||
assert_eq!(calls.snapshot(), vec!["S".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_secrets_become_empty_strings_and_are_reported() {
|
||||
let (out, missing) = interpolate_secrets_with(
|
||||
"a={{HAVE}}\nb={{MISSING_1}}\nc={{MISSING_2}}",
|
||||
None,
|
||||
|name| match name {
|
||||
"HAVE" => Ok("present".to_string()),
|
||||
missing => Err(not_found(missing)),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out, "a=present\nb=\nc=");
|
||||
assert_eq!(
|
||||
missing,
|
||||
vec!["MISSING_1".to_string(), "MISSING_2".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolates_multiple_secrets_on_same_line() {
|
||||
let calls = Calls::new();
|
||||
|
||||
let (out, missing) = interpolate_secrets_with("url={{URL}} key={{KEY}}", None, |name| {
|
||||
calls.record(name);
|
||||
match name {
|
||||
"URL" => Ok("https://example.test".to_string()),
|
||||
"KEY" => Ok("sk-12345".to_string()),
|
||||
other => panic!("unexpected lookup: {other}"),
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(calls.snapshot(), vec!["URL".to_string(), "KEY".to_string()]);
|
||||
assert_eq!(out, "url=https://example.test key=sk-12345");
|
||||
assert!(missing.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_rejects_braces_in_secret_names() {
|
||||
let calls = Calls::new();
|
||||
|
||||
let (out, missing) =
|
||||
interpolate_secrets_with("literal {{ {NOT_A_NAME} }} text", None, |name| {
|
||||
calls.record(name);
|
||||
Ok(format!("got-{name}"))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
calls.snapshot().is_empty(),
|
||||
"name with embedded braces must not match"
|
||||
);
|
||||
assert_eq!(out, "literal {{ {NOT_A_NAME} }} text");
|
||||
assert!(missing.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fatal_failure_short_circuits_remaining_lines() {
|
||||
let calls = Calls::new();
|
||||
|
||||
let result =
|
||||
interpolate_secrets_with("a={{S1}}\nb={{S2}}\nc={{S3}}\nd={{S4}}", None, |name| {
|
||||
calls.record(name);
|
||||
match name {
|
||||
"S1" => Ok("first".to_string()),
|
||||
"S2" => Err(auth_failed()),
|
||||
other => Ok(format!("late-{other}")),
|
||||
}
|
||||
});
|
||||
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("S2"),
|
||||
"error should name the offending secret, got: {err}"
|
||||
);
|
||||
assert_eq!(
|
||||
calls.snapshot(),
|
||||
vec!["S1".to_string(), "S2".to_string()],
|
||||
"lookups must stop at the failing secret - S3 and S4 should never be fetched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_failure_appends_hint_when_provided() {
|
||||
let result = interpolate_secrets_with(
|
||||
"k={{K}}",
|
||||
Some("run `coyote --authenticate` to reauth"),
|
||||
|_| Err(auth_failed()),
|
||||
);
|
||||
|
||||
let err = result.unwrap_err().to_string();
|
||||
|
||||
assert!(err.contains("Hint:"), "expected hint in error, got: {err}");
|
||||
assert!(
|
||||
err.contains("coyote --authenticate"),
|
||||
"expected hint contents, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user