Compare commits
482 Commits
v0.6.0
..
a3f278544a
| Author | SHA1 | Date | |
|---|---|---|---|
|
a3f278544a
|
|||
|
e48926f458
|
|||
|
4e616fe7c3
|
|||
|
863b28f01e
|
|||
|
bf97f2261d
|
|||
|
baa44ec5cb
|
|||
|
29af20f316
|
|||
|
960a199cd2
|
|||
|
3a18ffdaf3
|
|||
|
7aa00d52de
|
|||
|
ac58ddc202
|
|||
|
38259642cd
|
|||
|
90e105a171
|
|||
|
a5ece505b7
|
|||
|
fb8633dc75
|
|||
|
a7ebc15b89
|
|||
|
a7a9b6b1cf
|
|||
|
e1c2f0aa42
|
|||
|
6e9b394f73
|
|||
|
747ca0d0fc
|
|||
|
ba665528ed
|
|||
|
1440e23748
|
|||
|
8ff9d84a85
|
|||
|
dc8e831f27
|
|||
|
985ae11fcf
|
|||
|
b758b17dbb
|
|||
|
aef26013cb
|
|||
|
b1fc199a5f
|
|||
|
7e801b80d0
|
|||
|
7cd7abe469
|
|||
|
6a5561edba
|
|||
|
d8a92f4e62
|
|||
|
6330d7dd95
|
|||
|
c63eb0a9f9
|
|||
|
d927a9b99f
|
|||
|
766684615b
|
|||
|
5be12e90dc
|
|||
|
7325ad7b32
|
|||
|
2e224948d4
|
|||
|
fa424bde34
|
|||
|
42c88fa2a3
|
|||
|
84c6f88cf2
|
|||
|
f401c637cc
|
|||
|
3239c5d990
|
|||
|
1dff08893a
|
|||
|
f65ec9e9fe
|
|||
|
3ce5ab4fe7
|
|||
| fd21431c2f | |||
| 61a698f9eb | |||
|
518a39c143
|
|||
|
160ee5d5ae
|
|||
|
e4819ff9db
|
|||
|
ecb3cdfcc2
|
|||
|
364cf29296
|
|||
|
b2fa8ebb71
|
|||
| 48031d592f | |||
|
4c7a0a7a77
|
|||
|
8f960fdbbf
|
|||
|
feefe45ed2
|
|||
| 9958eeee8f | |||
|
aa31db7e07
|
|||
|
8c6dde7d86
|
|||
|
738e29059b
|
|||
|
2eb81c4a8b
|
|||
|
88998d1019
|
|||
|
a1fc099c24
|
|||
|
2fe365bef8
|
|||
|
efb1b7b96b
|
|||
|
d915f9e3c1
|
|||
|
11ebf3c155
|
|||
|
abf5d425fd
|
|||
|
1e3d52482a
|
|||
|
0fb72f8226
|
|||
|
0701c370b4
|
|||
|
0bdaa9441f
|
|||
|
4ba1bd8a24
|
|||
|
b484242e4c
|
|||
|
a756394e30
|
|||
|
0faf7b850d
|
|||
|
eeb9f7083b
|
|||
|
b6a5b340f1
|
|||
|
209257c7b1
|
|||
|
4e88cebe28
|
|||
|
738b600fa6
|
|||
|
f67538e5ab
|
|||
|
18bb3d3440
|
|||
|
04cd3c890b
|
|||
|
ef8f5865e2
|
|||
|
493e9bb2a5
|
|||
|
3eff135349
|
|||
|
7ac753d824
|
|||
|
9add71ff13
|
|||
|
7154c3a652
|
|||
|
36ac924d77
|
|||
|
5e4d3ff011
|
|||
|
fd287b09b0
|
|||
|
07c1f70df3
|
|||
|
8c398b6360
|
|||
|
e43c2e477a
|
|||
|
6078072915
|
|||
|
1902e2d040
|
|||
|
702e6f2f63
|
|||
|
01938a0f28
|
|||
|
5d017fbb48
|
|||
|
a3ed9476ae
|
|||
|
a22faad992
|
|||
|
06fe1f9471
|
|||
|
e2ff2c03f8
|
|||
|
7ca9a19d3b
|
|||
|
f5b69d6b4d
|
|||
|
4f244618ca
|
|||
|
b7a20a000a
|
|||
|
0094be475f
|
|||
|
3e508c9337
|
|||
|
edd3c08247
|
|||
|
bf6b2f718c
|
|||
|
984a073730
|
|||
|
aa4babff56
|
|||
|
7f620d469b
|
|||
|
33782c59a8
|
|||
|
5669830510
|
|||
|
99c6cff068
|
|||
|
9b395a304d
|
|||
|
01912bcef3
|
|||
|
e0b85fc936
|
|||
|
73f6e07e47
|
|||
|
534b9923ae
|
|||
|
c66faa22dc
|
|||
|
cf666eb2c6
|
|||
|
76861508c9
|
|||
|
beebb39050
|
|||
|
3d7ba424f1
|
|||
|
84eb82b355
|
|||
|
1fbdcd66d1
|
|||
|
5b65496684
|
|||
|
ca808b4c08
|
|||
|
84c1753ed5
|
|||
|
c8d9f89d59
|
|||
|
e3531b4dcf
|
|||
|
b939868d28
|
|||
|
7630b3e75c
|
|||
| 3292f8e0a5 | |||
|
cf1c06e632
|
|||
|
49f2932b30
|
|||
|
5fd786dd3d
|
|||
|
f5967c7771
|
|||
|
eee0e86131
|
|||
|
51dfd2a655
|
|||
|
d9cf0c4b08
|
|||
|
b4c65f7a19
|
|||
|
1c0e836a92
|
|||
|
2da196c091
|
|||
|
69648afe27
|
|||
|
454f5c03f3
|
|||
|
406642723e
|
|||
|
2469b713c7
|
|||
|
b6ad7a575d
|
|||
|
f3b410d146
|
|||
|
095d0f3d8a
|
|||
|
5f445e046f
|
|||
|
96ab2bdc1b
|
|||
|
cb175e3b51
|
|||
|
7965b970d9
|
|||
|
0a21f10b04
|
|||
|
49aa9fad41
|
|||
|
8f7d3bd13c
|
|||
|
f7fb249d43
|
|||
|
d9498ffb21
|
|||
|
0177fa6906
|
|||
|
c3f6cb8f46
|
|||
|
7facdce6b6
|
|||
|
c11eb352fe
|
|||
|
0e427dc4ba
|
|||
|
f1914f6bd4
|
|||
|
dba6304f51
|
|||
|
e40a8bba72
|
|||
|
c057249e52
|
|||
|
d906713d7d
|
|||
| ff3419a714 | |||
|
a5899da4fb
|
|||
|
dedcef8ac5
|
|||
|
d658f1d2fe
|
|||
|
6b4a45874f
|
|||
|
7839e1dbd9
|
|||
|
78c3932f36
|
|||
|
11334149b0
|
|||
|
4caa035528
|
|||
|
f30e81af08
|
|||
|
4c75655f58
|
|||
|
f865892c28
|
|||
|
ebeb9c9b7d
|
|||
|
ab2b927fcb
|
|||
|
7e5ff2ba1f
|
|||
|
ed59051f3d
|
|||
| e98bf56a2b | |||
| fb510b1a4f | |||
|
6c17462040
|
|||
|
1536cf384c
|
|||
|
d6842d7e29
|
|||
|
fbc0acda2a
|
|||
|
0327d041b6
|
|||
|
6a01fd4fbd
|
|||
| d822180205 | |||
|
89d0fdce26
|
|||
|
b3ecdce979
|
|||
|
3873821a31
|
|||
|
9c2801b643
|
|||
|
d78820dcd4
|
|||
|
d43c4232a2
|
|||
|
f41c85b703
|
|||
|
9e056bdcf0
|
|||
|
d6022b9f98
|
|||
|
6fc1abf94a
|
|||
|
92ea0f624e
|
|||
|
c3fd8fbc1c
|
|||
|
7fd3f7761c
|
|||
|
05e19098b2
|
|||
|
60067ae757
|
|||
|
c72003b0b6
|
|||
|
7c9d500116
|
|||
|
6b2c87b562
|
|||
|
b2dbdfb4b1
|
|||
|
063e198f96
|
|||
|
73cbe16ec1
|
|||
|
bdea854a9f
|
|||
|
9b4c800597
|
|||
|
eb4d1c02f4
|
|||
|
c428990900
|
|||
|
03b9cc70b9
|
|||
|
3fa0eb832c
|
|||
|
83f66e1061
|
|||
|
741b9c364c
|
|||
|
b6f6f456db
|
|||
|
00a6cf74d7
|
|||
|
d35ca352ca
|
|||
| 57dc1cb252 | |||
|
101a9cdd6e
|
|||
|
c5f52e1efb
|
|||
|
470149b606
|
|||
|
02062c5a50
|
|||
|
e6e99b6926
|
|||
|
15a293204f
|
|||
|
ecf3780aed
|
|||
|
e798747135
|
|||
|
60493728a0
|
|||
|
25d6370b20
|
|||
|
d67f845af5
|
|||
|
920a14cabe
|
|||
|
58bdd2e584
|
|||
|
ce6f53ad05
|
|||
|
96f8007d53
|
|||
|
32a55652fe
|
|||
|
2b92e6c98b
|
|||
|
cfa654bcd8
|
|||
|
d0f5ae39e2
|
|||
|
2bb8cf5f73
|
|||
|
fbac446859
|
|||
|
f91cf2e346
|
|||
|
b6b33ab7e3
|
|||
|
c1902a69d1
|
|||
|
812a8e101c
|
|||
|
655ee2a599
|
|||
|
128a8f9a9c
|
|||
|
b1be9443e7
|
|||
|
7b12c69ebf
|
|||
|
69ad584137
|
|||
|
313058e70a
|
|||
|
ea96d9ba3d
|
|||
|
7884adc7c1
|
|||
|
948466d771
|
|||
|
3894c98b5b
|
|||
|
5e9c31595e
|
|||
|
39d9b25e47
|
|||
|
b86f76ddb9
|
|||
|
7f267a10a1
|
|||
|
cdafdff281
|
|||
|
60ad83d6d9
|
|||
|
44c03ccf4f
|
|||
|
af933bbb29
|
|||
|
1f127ee990
|
|||
|
88a9a7709f
|
|||
| e8d92d1b01 | |||
| ddbfd03e75 | |||
|
d1c7f09015
|
|||
|
d2f8f995f0
|
|||
|
5ef9a397ca
|
|||
|
325ab1f45e
|
|||
|
4cfaa2dc77
|
|||
|
6abe2c5536
|
|||
|
03cfd59962
|
|||
|
4d7d5e5e53
|
|||
|
3779b940ae
|
|||
|
d2e541c5c0
|
|||
|
621c90427c
|
|||
|
486001ee85
|
|||
|
c7a2ec084f
|
|||
|
d4e0d48198
|
|||
|
07f23bab5e
|
|||
|
b11797ea1c
|
|||
|
70c2d411ae
|
|||
|
f82c9aff40
|
|||
|
a935add2a7
|
|||
|
8a37a88ffd
|
|||
|
8f66cac680
|
|||
|
0a40ddd2e4
|
|||
|
d5e0728532
|
|||
|
25c0885dcc
|
|||
|
f56ed7d005
|
|||
|
d79e4b9dff
|
|||
|
cdd829199f
|
|||
|
e3c644b8ca
|
|||
|
5cb8070da1
|
|||
|
66801b5d07
|
|||
|
f2de196e22
|
|||
|
2eba530895
|
|||
| 3baa3102a3 | |||
| 2d4fad596c | |||
| 7259e59d2a | |||
| cec04c4597 | |||
| a7f5677195 | |||
| 6075f0a190 | |||
| 15310a9e2c | |||
| f7df54f2f7 | |||
| 212d4bace4 | |||
| f4b3267c89 | |||
| 9eeeb11871 | |||
| b8db3f689d | |||
| 3b21ce2aa5 | |||
| 9bf4fcd943 | |||
| c1f5cfbbda | |||
| 46517a4e15 | |||
| efbe76e1fc | |||
| 245c567d30 | |||
| cbb3d2c34a | |||
| bddec85fa5 | |||
| 96acbc6bf0 | |||
| 0735a31190 | |||
| 986c64ff13 | |||
| 831426d418 | |||
| b99e3fc030 | |||
| 012734f70a | |||
| f591a9635e | |||
| 7c099bf589 | |||
| 32d3cee907 | |||
| 86539c4bb8 | |||
| 14549afd52 | |||
| 667c843fc0 | |||
| 680a52982c | |||
| 52efb1a775 | |||
| c88931d318 | |||
| 2183ed62d1 | |||
| cc8bd040b9 | |||
| a2a464151f | |||
| c9a3f247e7 | |||
| d167502b7b | |||
| 0d9927bb99 | |||
| c9858ce615 | |||
| cccaa1dbe7 | |||
| acd951e981 | |||
| 10d80d58fd | |||
| f196c375d6 | |||
| cc62c89b05 | |||
| 3266cdeb08 | |||
| 6605c62015 | |||
| 704fdbd145 | |||
| 93e76a65a1 | |||
| b3ca7ebddb | |||
| 091fc0b7b7 | |||
| 874f5ba08e | |||
| 5fdfe94b88 | |||
| c02b168749 | |||
| 6ababd919d | |||
| 86b2b2d772 | |||
| 2aa2c3ccee | |||
| 70645a8431 | |||
| ca4b2f2637 | |||
| 7fce8f9b23 | |||
| e5b3b332f6 | |||
| 3e59762443 | |||
| 2ea8a48f28 | |||
| 3c07471620 | |||
| 23e2c1144f | |||
| 313f5e2dda | |||
| 26c35e55d8 | |||
| 878adc0eb7 | |||
| d353767b2c | |||
| 33baeaa62d | |||
| 591b7a5bf1 | |||
| 0bc993532b | |||
| 09379e7231 | |||
| 1a45ce9dc1 | |||
| 95df054dfb | |||
| 5b49553c6d | |||
| 6508940d11 | |||
| 71d89eaaba | |||
| 9619b7908f | |||
| 304129d793 | |||
| 5df435c21a | |||
| 2719c7320a | |||
| a84bae189c | |||
| d82c7c2535 | |||
| 2bc832ed95 | |||
| b5a0f0635b | |||
| 7426aa4bcb | |||
| ba9649382e | |||
| 9c64e97d8b | |||
| 4b1cd3cf44 | |||
| 4a0f002503 | |||
| c4f8c6e102 | |||
| 421308423f | |||
| 0550de2093 | |||
| dddf72e1da | |||
| e23820adf2 | |||
| fea4411aa6 | |||
| b814a38c59 | |||
| 1a3476e4fb | |||
| ecd4d6587c | |||
| 0938119e99 | |||
| 9f15f01871 | |||
| f09cbd2b32 | |||
| 77c1a06277 | |||
| 600f5d1484 | |||
| 7f71317acd | |||
| 865ef5827b | |||
| e5d5bf6c53 | |||
| 7b08d1ef96 | |||
| 9d363b38c7 | |||
| 2f3586cbbf | |||
| 843abe0621 | |||
| 474c5bc76f | |||
| b49a27f886 | |||
| 6f77b3f46e | |||
| a835012673 | |||
| 3f1e8003f8 | |||
| 8475707e75 | |||
| 8a240b1c3f | |||
| 59a3e3012b | |||
| c13142f971 | |||
| a468ee1154 | |||
| 1b504e211a | |||
| 29536f6291 | |||
| 4ef483126d | |||
| 8d2961f3ee | |||
| f1146bb2b9 | |||
| 2daa014c99 | |||
| ebe642f44a | |||
| 25ad254e84 | |||
| 947a7871c2 | |||
| 6421a677eb | |||
| 950893f4a2 | |||
| a10948614d | |||
| 39fc863e22 | |||
| df8b326d89 | |||
| 591f204b67 | |||
| 316ebd6d25 | |||
| 4e707ae08e | |||
| 1ef554c759 | |||
| 367e7d90fd | |||
| 6e7a89763c | |||
| 9dd3836802 | |||
| f822546971 | |||
| 4bf338f91a | |||
| 16577ddc5e | |||
| 384ae73c80 | |||
| d4c932b8ac | |||
| 743e42d4f8 | |||
| 6be2651106 | |||
| 2a2d20a25c | |||
| 882942385b | |||
| 0aa908c8d3 | |||
| 4c179c9269 | |||
| a4fe91ffda | |||
| dc500207ef | |||
| c1e3c3699b | |||
| 52e9f5fc70 | |||
| c85cddb5b4 | |||
| 477b53124d | |||
| 650dbd92e0 | |||
| 88288a98b6 | |||
| 377ab91af7 | |||
| acfc7685f4 | |||
| 5636010e1e |
@@ -1,84 +1,3 @@
|
||||
## 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
+98
-92
@@ -278,9 +278,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-config"
|
||||
version = "1.8.18"
|
||||
version = "1.8.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e33f815b73a3899c03b380d543532e5865f230dce9678d108dc10732a8682275"
|
||||
checksum = "517aa062d8bd9015ee23d6daa5e1c1372328412fdae4e6c4c1be9b69c6ad37a2"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
@@ -381,11 +381,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-secretsmanager"
|
||||
version = "1.107.0"
|
||||
version = "1.105.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63da8ec2dca98a68d8bcba971abae5f06e2c9c0017f43097d1ff92cff96adc54"
|
||||
checksum = "1c4e56ac810211dc33810c7aa3612eda29a8b1e8c7e2db6e960c8657e3d95e42"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
@@ -406,11 +405,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sso"
|
||||
version = "1.101.0"
|
||||
version = "1.99.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b647baea49ff551960b904f905681e9b4765a6c4ea08631e89dc52d8bd3f5896"
|
||||
checksum = "9f4055e6099b2ec264abdc0d9bbfffce306c1601809275c861594779a0b04b45"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
@@ -431,11 +429,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-ssooidc"
|
||||
version = "1.103.0"
|
||||
version = "1.101.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ae401c65ff288aa7873117fe535cd32b7b1bb0bc43751d28901a1d5f20636b9"
|
||||
checksum = "02f009ba0284c5d696425fd7b4dcc5b189f5726f4041b7a5794daecb3a68d598"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
@@ -456,11 +453,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sts"
|
||||
version = "1.106.0"
|
||||
version = "1.104.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c80de7bb7d03e9ca8c9fd7b489f20f3948d3f3be91a7953591347d238115408"
|
||||
checksum = "6aa6622798e19e6a76b690562085dd4771c736cd48343464a53ab4ae2f2c9f84"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
@@ -482,9 +478,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sigv4"
|
||||
version = "1.4.5"
|
||||
version = "1.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71"
|
||||
checksum = "b7083fb918b38474ac65ffbf8a69fc8792d36879f4ac5f1667b43aec61efe9a5"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-http",
|
||||
@@ -547,9 +543,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http-client"
|
||||
version = "1.1.13"
|
||||
version = "1.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3"
|
||||
checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -560,7 +556,7 @@ dependencies = [
|
||||
"http 1.4.1",
|
||||
"http-body 0.4.6",
|
||||
"hyper 0.14.32",
|
||||
"hyper 1.10.1",
|
||||
"hyper 1.10.0",
|
||||
"hyper-rustls 0.24.2",
|
||||
"hyper-rustls 0.27.9",
|
||||
"hyper-util",
|
||||
@@ -577,9 +573,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-json"
|
||||
version = "0.62.7"
|
||||
version = "0.62.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa"
|
||||
checksum = "517089205f18ab4adc5a3e02888cb139bbbbb2e168eac9f396216925d1fbeaf5"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
@@ -633,9 +629,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime-api"
|
||||
version = "1.12.3"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c"
|
||||
checksum = "dc117c179ecf39a62a0a3f49f600e9ac26a7ad7dd172177999f83933af776c32"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api-macros",
|
||||
@@ -673,9 +669,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-types"
|
||||
version = "1.4.9"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53f93074121a1be41317b9aa607143ae17900631f7f59a99f2b905d519d6783b"
|
||||
checksum = "056b66dbce2f81cc0c1e2b05bb402eb58f8a3530479d650efadd5bbae9a4050b"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -903,7 +899,7 @@ dependencies = [
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex 1.3.0",
|
||||
"shlex",
|
||||
"syn",
|
||||
]
|
||||
|
||||
@@ -924,9 +920,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.12.1"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -1060,14 +1056,14 @@ checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.63"
|
||||
version = "1.2.62"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex 2.0.1",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1128,9 +1124,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
@@ -1194,7 +1190,7 @@ dependencies = [
|
||||
"clap",
|
||||
"clap_lex",
|
||||
"is_executable",
|
||||
"shlex 1.3.0",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1245,9 +1241,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
@@ -1402,7 +1398,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "coyote-ai"
|
||||
version = "0.6.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"ansi_colours",
|
||||
"anyhow",
|
||||
@@ -2337,7 +2333,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"hyper 1.10.1",
|
||||
"hyper 1.10.0",
|
||||
"jsonwebtoken",
|
||||
"once_cell",
|
||||
"prost",
|
||||
@@ -2770,9 +2766,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -2812,7 +2808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http 1.4.1",
|
||||
"hyper 1.10.1",
|
||||
"hyper 1.10.0",
|
||||
"hyper-util",
|
||||
"rustls 0.23.40",
|
||||
"rustls-native-certs",
|
||||
@@ -2827,7 +2823,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
||||
dependencies = [
|
||||
"hyper 1.10.1",
|
||||
"hyper 1.10.0",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
@@ -2842,7 +2838,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper 1.10.1",
|
||||
"hyper 1.10.0",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
@@ -2862,12 +2858,12 @@ dependencies = [
|
||||
"futures-util",
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"hyper 1.10.1",
|
||||
"hyper 1.10.0",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.4",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3157,9 +3153,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.28"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
|
||||
checksum = "392c70591e8749fe235ddaf513e6f58b26bce3dcc16524cecc8936f75afa161e"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
@@ -3170,9 +3166,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.28"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
|
||||
checksum = "47b605b0c050d845fc355bb11eb3f9a8deddc218ea60c76e61aa1f2adfb2c96a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3355,9 +3351,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.32"
|
||||
version = "0.4.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -3515,9 +3511,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4416,7 +4412,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.40",
|
||||
"socket2 0.6.4",
|
||||
"socket2 0.6.3",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -4454,7 +4450,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.4",
|
||||
"socket2 0.6.3",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -4672,7 +4668,7 @@ dependencies = [
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.10.1",
|
||||
"hyper 1.10.0",
|
||||
"hyper-rustls 0.27.9",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
@@ -4718,7 +4714,7 @@ dependencies = [
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.10.1",
|
||||
"hyper 1.10.0",
|
||||
"hyper-rustls 0.27.9",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
@@ -4821,9 +4817,9 @@ checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "7.5.4"
|
||||
version = "7.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
||||
checksum = "835a57a69104632d64deb0df2e09a69945cd7a6eab4070fc9b1d7e50cf6c3edc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rtoolbox",
|
||||
@@ -4948,9 +4944,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
@@ -5038,6 +5034,15 @@ 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"
|
||||
@@ -5116,6 +5121,12 @@ 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"
|
||||
@@ -5309,9 +5320,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
version = "3.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
||||
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bs58",
|
||||
@@ -5329,9 +5340,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.21.0"
|
||||
version = "3.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
||||
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
@@ -5354,23 +5365,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serial_test"
|
||||
version = "3.5.0"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d"
|
||||
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
|
||||
dependencies = [
|
||||
"futures-executor",
|
||||
"futures-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"scc",
|
||||
"serial_test_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial_test_derive"
|
||||
version = "3.5.0"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c"
|
||||
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5448,12 +5460,6 @@ 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"
|
||||
@@ -5576,9 +5582,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.4"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -6013,7 +6019,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.4",
|
||||
"socket2 0.6.3",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -6124,13 +6130,13 @@ dependencies = [
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.10.1",
|
||||
"hyper 1.10.0",
|
||||
"hyper-timeout",
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"rustls-native-certs",
|
||||
"socket2 0.6.4",
|
||||
"socket2 0.6.3",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
@@ -6305,9 +6311,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "typespec"
|
||||
@@ -6380,9 +6386,9 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
@@ -6517,9 +6523,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.2"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -7412,9 +7418,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
@@ -7435,18 +7441,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.50"
|
||||
version = "0.8.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
||||
checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.50"
|
||||
version = "0.8.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
||||
checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coyote-ai"
|
||||
version = "0.6.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
||||
description = "An all-in-one, batteries included LLM CLI Tool"
|
||||
|
||||
@@ -14,21 +14,6 @@ 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
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
name: explore
|
||||
description: Fast codebase exploration agent - finds patterns, structures, and relevant files. Designed to be fanned out 2-5 in parallel by orchestrators.
|
||||
version: 3.0.0
|
||||
version: 2.0.0
|
||||
|
||||
skills_enabled: true
|
||||
enabled_skills:
|
||||
- ai-slop-remover
|
||||
enabled_skills: []
|
||||
|
||||
variables:
|
||||
- name: project_dir
|
||||
@@ -23,85 +22,64 @@ global_tools:
|
||||
instructions: |
|
||||
You are a codebase explorer. Your job: Search, find, report. Nothing else.
|
||||
|
||||
## Step 0: Load your skills
|
||||
|
||||
At the start of every exploration, call `skill__load` for `ai-slop-remover`. Your findings go directly into the orchestrator's synthesis, so concise, slop-free output is the contract. Apply the skill's standards to your final findings block:
|
||||
|
||||
- No filler ("It's important to note that…", "Let me explain…"). Just the finding.
|
||||
- No flattery, no padding, no status updates about your process.
|
||||
- No multi-paragraph commentary — bullet points with code snippets are enough.
|
||||
|
||||
## You may be one of many parallel explorers
|
||||
|
||||
Orchestrators (like Sisyphus) often fan out 2-5 explore agents at once, each covering a different angle of the same question. Assume you are ONE narrow slice of a larger investigation. Stay strictly within YOUR slice as defined by the prompt — don't broaden scope to cover what other parallel explorers might be handling.
|
||||
|
||||
If the prompt says "find auth middleware", you find auth middleware. You do NOT also tour the routing layer, the error system, and the database connection pool. Narrow scope is the contract.
|
||||
|
||||
## Investigation methodology
|
||||
## Your mission
|
||||
|
||||
Before searching, build a quick mental model. Then narrow in. Then read.
|
||||
1. Search for relevant files and patterns within YOUR slice.
|
||||
2. Read key files to understand structure.
|
||||
3. Report findings concisely.
|
||||
4. Signal completion with `EXPLORE_COMPLETE`.
|
||||
|
||||
1. **Frame the question.** What kind of artifact am I looking for? Symbols (struct/class/function)? File patterns? Configuration? Implementation details? Tests? Different artifact kinds use different tools.
|
||||
## File reading strategy (minimize token usage)
|
||||
|
||||
2. **Find first, read second.** Never `fs_read` a file without knowing why you're reading it.
|
||||
|
||||
3. **Build a directory mental model with `fs_ls` and `fs_glob`** — `fs_ls src/` to see what's there; `fs_glob '**/*.rs' src/` to see which files exist by name.
|
||||
|
||||
4. **Locate symbols with `fs_grep`** — for finding where things live across the codebase. `fs_grep --pattern "fn handle_request" --include "*.rs"` is faster than reading files.
|
||||
|
||||
5. **Read targeted sections with `fs_read --offset/--limit`** — `fs_read --path "src/main.rs" --offset 50 --limit 30` reads lines 50-79 only. `fs_read` adds line numbers but TRUNCATES long lines (over 2000 chars) and caps output at 2000 lines by default.
|
||||
|
||||
6. **Use `fs_cat` only when you need the full untruncated file** — rare in exploration. If you reach for `fs_cat`, ask whether `fs_grep` + targeted `fs_read` would answer your question with less context spend.
|
||||
|
||||
7. **Never read entire large files** — for files 500+ lines, read the relevant section only.
|
||||
1. **Find first, read second** — never read a file without knowing why.
|
||||
2. **Use grep to locate** — `fs_grep --pattern "struct User" --include "*.rs"` finds where things are.
|
||||
3. **Use glob to discover** — `fs_glob --pattern "*.rs" --path src/` finds files by name.
|
||||
4. **Prefer `fs_read` with offset/limit** — `fs_read --path "src/main.rs" --offset 50 --limit 30` reads lines 50-79 only. `fs_read` adds line numbers but TRUNCATES long lines (over 2000 chars) and caps output at 2000 lines by default.
|
||||
5. **Use `fs_cat` only when you need the entire file untruncated** — for exploration this should be rare. If you find yourself reaching for `fs_cat`, ask whether `fs_grep` + a targeted `fs_read` would answer your question instead.
|
||||
6. **Never read entire large files** — if a file is 500+ lines, read the relevant section only.
|
||||
|
||||
## Available actions
|
||||
|
||||
- `fs_grep --pattern "struct User" --include "*.rs"` — find content across files in a directory tree
|
||||
- `fs_grep --pattern "TODO" --path "src/main.rs"` — find content within a single file (--include is ignored in this mode)
|
||||
- `fs_grep --pattern "struct User" --include "*.rs"` — find content across files
|
||||
- `fs_glob --pattern "*.rs" --path src/` — find files by name pattern
|
||||
- `fs_read --path "src/main.rs"` — read a TRUNCATED view with line numbers (default 2000 lines, lines over 2000 chars cut off)
|
||||
- `fs_read --path "src/main.rs" --offset 100 --limit 50` — read lines 100-149 only (line numbers; truncation rules still apply)
|
||||
- `fs_read --path "src/main.rs" --offset 100 --limit 50` — read lines 100-149 only (with line numbers, truncation rules still apply)
|
||||
- `fs_cat --path "src/main.rs"` — read the FULL untruncated file (no line numbers); use only when you actually need every line
|
||||
- `fs_ls --path "src/"` — list directory contents
|
||||
|
||||
## When to use the web (ddg-search MCP)
|
||||
|
||||
Rarely. You are a CODEBASE explorer, not a web researcher. Use the web only when the codebase references an external library/framework whose documented behavior is the answer to the question (e.g., "how does Tokio's #[tokio::main] expand"), and the answer isn't in the local code. For internal questions ("how does OUR auth work"), grep the codebase — never the web.
|
||||
|
||||
## Output format
|
||||
|
||||
Always end your response with a structured findings block. Sisyphus reads this verbatim and may paste sections directly into delegation prompts for a coder agent, so the structure matters:
|
||||
Always end your response with a findings summary. Include actual code snippets when they show the pattern — file paths alone are not enough for the orchestrator to delegate downstream:
|
||||
|
||||
```
|
||||
FINDINGS:
|
||||
- [One-line concrete fact about what you found]
|
||||
- [Another one-line fact]
|
||||
- Relevant files: [list of paths, no commentary]
|
||||
- [Key finding 1]
|
||||
- [Key finding 2]
|
||||
- Relevant files: [list]
|
||||
|
||||
Code patterns (paste actual lines):
|
||||
- From `path/to/file.ext` lines N-M:
|
||||
<5-20 lines of actual code that show the pattern>
|
||||
- From `path/to/other.ext` lines N-M:
|
||||
<another snippet>
|
||||
|
||||
Open questions (only if any):
|
||||
- [Anything you couldn't determine and the orchestrator should clarify or delegate elsewhere]
|
||||
<snippet>
|
||||
|
||||
EXPLORE_COMPLETE
|
||||
```
|
||||
|
||||
Pasting actual code lines (5-20 per pattern) lets the orchestrator hand snippets directly to a coder agent without re-exploration. That is the entire point of your existence in a parallel research phase. File paths alone make downstream delegation impossible — the coder would have to re-do your work.
|
||||
Pasting actual code lines (5-20 lines per pattern) lets the orchestrator hand the snippet directly to a coder agent without re-exploration. That is the whole point of your existence in a fanned-out research phase.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Be fast.** Don't read every file, read representative ones.
|
||||
2. **Stay in your slice.** Narrow scope is the contract.
|
||||
3. **Be concise.** Report findings, not your process. Apply the `ai-slop-remover` skill to your output.
|
||||
4. **Never modify files.** You are read-only.
|
||||
5. **Limit reads.** Target around 5 file reads per exploration; go higher only when the question genuinely requires it.
|
||||
6. **Paste code snippets.** File paths alone make downstream delegation impossible.
|
||||
7. **Report what you didn't find.** If the prompt asked for X and X doesn't exist in your slice, say so explicitly — don't pad your findings with adjacent material to hide the gap.
|
||||
1. **Be fast** — don't read every file, read representative ones.
|
||||
2. **Stay in your slice** — narrow scope is the contract.
|
||||
3. **Be concise** — report findings, not your process.
|
||||
4. **Never modify files** — you are read-only.
|
||||
5. **Limit reads** — max 5 file reads per exploration.
|
||||
6. **Paste code snippets** — file paths alone make downstream delegation impossible.
|
||||
|
||||
## Context
|
||||
- Project: {{project_dir}}
|
||||
|
||||
@@ -239,52 +239,11 @@ instructions: |
|
||||
|
||||
**No evidence = not complete.** Mark a todo `completed` only after evidence is collected.
|
||||
|
||||
### Independent code review (post-coder, non-trivial work)
|
||||
|
||||
After completing delegated `coder` work, spawn `code-reviewer` for an independent review pass if ANY of these are true:
|
||||
|
||||
1. **2+ coder agents were spawned** for this task (multi-component change; no single coder saw the whole picture)
|
||||
2. **A single coder touched 5+ files** (broad-scope change; harder for self-review to hold in one context)
|
||||
3. **The change crosses architectural boundaries** — auth, public APIs, security-sensitive paths, schema/migration files, configuration that affects multiple services
|
||||
4. **You judge the change as architecturally significant** even if 1-3 don't trigger
|
||||
|
||||
If none of these fire, the work is "single coder, narrow scope, mechanical" — coder's internal `self_review` is sufficient.
|
||||
|
||||
**Why this matters.** Coder's `self_review` is a same-agent check: the agent that wrote the code reviews its own diff. It catches surface slop and obvious mistakes, but it's structurally weak at catching cross-cutting issues across parallel coders, subtle design problems the author justified to themselves, and rationalized "not my job" footguns. `code-reviewer` is independent — no commitment to the prior design decisions. The independence is the value, and it's how real-world engineering catches what authors miss.
|
||||
|
||||
**Spawn pattern:**
|
||||
|
||||
```
|
||||
agent__spawn --agent code-reviewer --prompt "Review the changes from the recent coder run(s) for this task.
|
||||
|
||||
Original request: <one-line summary of what the user asked for>
|
||||
Scope: <which directories or files the changes are expected to touch>
|
||||
|
||||
Coder summaries:
|
||||
- <coder 1 session_id>: <plan_summary from CODER_COMPLETE>
|
||||
- <coder 2 session_id>: <plan_summary if multiple coders ran>
|
||||
|
||||
Run `get_diff` against the staged or recent changes, fan out file-reviewers per changed file as usual, and synthesize."
|
||||
```
|
||||
|
||||
### Handling code-reviewer findings
|
||||
|
||||
- **🔴 CRITICAL** findings block completion. Spawn `coder` to fix — preferably the SAME session as the original coder (`agent__spawn --session_id <id> --prompt "Fix: <critical findings pasted verbatim>"`). Do NOT re-spawn `code-reviewer` automatically after the fix; coder's own `self_review` on the fix is sufficient unless the fix itself was substantial (5+ files or architectural).
|
||||
- **🟡 WARNING** findings are blocking unless the work was explicitly scoped to defer them. If unsure, ASK the user via `user__ask` whether to fix or accept.
|
||||
- **🟢 SUGGESTION / 💡 NITPICK** findings are informational. Surface them to the user with the final report. Do not block on them.
|
||||
- **`Pre-existing, out of scope:` findings** — surface to the user but do not act on them. They predate this work and aren't the current task's responsibility.
|
||||
|
||||
### When NOT to re-spawn code-reviewer
|
||||
|
||||
After a fix-loop completes, do not automatically re-run `code-reviewer` unless the fix itself triggers the same thresholds (2+ coders, 5+ files, architectural). Each `code-reviewer` invocation fans out N file-reviewers per changed file; spurious re-runs burn budget without proportional value. Trust coder's `self_review` on bounded fixes.
|
||||
|
||||
## File Operations (Direct Edits)
|
||||
|
||||
When you write or modify files yourself (rather than delegating to coder):
|
||||
|
||||
- **For editing an existing file**, prefer `fs_patch`. It's a surgical edit that preserves unchanged content. Send only the diff hunks for the lines you want to change; do not re-send the whole file. This is faster, cheaper, and dramatically less prone to accidental data loss than a full rewrite.
|
||||
- **For writing a NEW file or doing a COMPLETE rewrite**, use `fs_write`. Use it only when most of the content is changing or the file doesn't exist yet.
|
||||
- **NEVER write files via `execute_command`.** Do not use:
|
||||
- **For writing files**, ALWAYS use `fs_write` (new file / full overwrite) or `fs_patch` (surgical edit). NEVER write files via `execute_command`. Do not use:
|
||||
- `cat > file`, `cat >> file`, `tee`
|
||||
- `echo >`, `printf >`
|
||||
- Heredocs (`<<EOF`, `<<-EOF`, `<<'EOF'`)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"atlassian": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote@latest", "https://mcp.atlassian.com/v1/mcp"]
|
||||
"args": ["-y", "mcp-remote@0.1.13", "https://mcp.atlassian.com/v1/mcp"]
|
||||
},
|
||||
"docker": {
|
||||
"type": "stdio",
|
||||
|
||||
@@ -32,7 +32,7 @@ def main():
|
||||
agent_data = parse_raw_data(raw_data)
|
||||
|
||||
root_dir = "{config_dir}"
|
||||
setup_env(root_dir, agent_func, raw_data)
|
||||
setup_env(root_dir, agent_func)
|
||||
|
||||
agent_tools_path = os.path.join(root_dir, "agents/{agent_name}/tools.py")
|
||||
run(agent_tools_path, agent_func, agent_data)
|
||||
@@ -65,14 +65,13 @@ def parse_argv():
|
||||
return agent_func, agent_data
|
||||
|
||||
|
||||
def setup_env(root_dir, agent_func, raw_data):
|
||||
def setup_env(root_dir, agent_func):
|
||||
load_env(os.path.join(root_dir, ".env"))
|
||||
os.environ["LLM_ROOT_DIR"] = root_dir
|
||||
os.environ["LLM_AGENT_NAME"] = "{agent_name}"
|
||||
os.environ["LLM_AGENT_FUNC"] = agent_func
|
||||
os.environ["LLM_AGENT_ROOT_DIR"] = os.path.join(root_dir, "agents", "{agent_name}")
|
||||
os.environ["LLM_AGENT_CACHE_DIR"] = os.path.join(root_dir, "cache", "{agent_name}")
|
||||
os.environ["LLM_AGENT_RAW_JSON"] = raw_data
|
||||
|
||||
|
||||
def load_env(file_path):
|
||||
|
||||
@@ -32,7 +32,6 @@ setup_env() {
|
||||
export LLM_AGENT_ROOT_DIR="$LLM_ROOT_DIR/agents/{agent_name}"
|
||||
export LLM_AGENT_CACHE_DIR="$LLM_ROOT_DIR/cache/{agent_name}"
|
||||
export LLM_PROMPT_UTILS_FILE="{prompt_utils_file}"
|
||||
export LLM_AGENT_RAW_JSON="$agent_data"
|
||||
}
|
||||
|
||||
load_env() {
|
||||
|
||||
@@ -11,7 +11,7 @@ async function main(): Promise<void> {
|
||||
const agentData = parseRawData(rawData);
|
||||
|
||||
const configDir = "{config_dir}";
|
||||
setupEnv(configDir, agentFunc, rawData);
|
||||
setupEnv(configDir, agentFunc);
|
||||
|
||||
const agentToolsPath = join(configDir, "agents", "{agent_name}", "tools.ts");
|
||||
await run(agentToolsPath, agentFunc, agentData);
|
||||
@@ -48,14 +48,13 @@ function parseArgv(): { agentFunc: string; rawData: string } {
|
||||
return { agentFunc, rawData: agentData };
|
||||
}
|
||||
|
||||
function setupEnv(configDir: string, agentFunc: string, rawData: string): void {
|
||||
function setupEnv(configDir: string, agentFunc: string): void {
|
||||
loadEnv(join(configDir, ".env"));
|
||||
process.env["LLM_ROOT_DIR"] = configDir;
|
||||
process.env["LLM_AGENT_NAME"] = "{agent_name}";
|
||||
process.env["LLM_AGENT_FUNC"] = agentFunc;
|
||||
process.env["LLM_AGENT_ROOT_DIR"] = join(configDir, "agents", "{agent_name}");
|
||||
process.env["LLM_AGENT_CACHE_DIR"] = join(configDir, "cache", "{agent_name}");
|
||||
process.env["LLM_AGENT_RAW_JSON"] = rawData;
|
||||
}
|
||||
|
||||
function loadEnv(filePath: string): void {
|
||||
|
||||
@@ -32,7 +32,7 @@ def main():
|
||||
tool_data = parse_raw_data(raw_data)
|
||||
|
||||
root_dir = "{root_dir}"
|
||||
setup_env(root_dir, raw_data)
|
||||
setup_env(root_dir)
|
||||
|
||||
tool_path = "{tool_path}.py"
|
||||
run(tool_path, "run", tool_data)
|
||||
@@ -65,12 +65,11 @@ def parse_argv():
|
||||
return tool_data
|
||||
|
||||
|
||||
def setup_env(root_dir, raw_data):
|
||||
def setup_env(root_dir):
|
||||
load_env(os.path.join(root_dir, ".env"))
|
||||
os.environ["LLM_ROOT_DIR"] = root_dir
|
||||
os.environ["LLM_TOOL_NAME"] = "{function_name}"
|
||||
os.environ["LLM_TOOL_CACHE_DIR"] = os.path.join(root_dir, "cache", "{function_name}")
|
||||
os.environ["LLM_TOOL_RAW_JSON"] = raw_data
|
||||
|
||||
|
||||
def load_env(file_path):
|
||||
|
||||
@@ -29,7 +29,6 @@ setup_env() {
|
||||
export LLM_TOOL_NAME="{function_name}"
|
||||
export LLM_TOOL_CACHE_DIR="$LLM_ROOT_DIR/cache/{function_name}"
|
||||
export LLM_PROMPT_UTILS_FILE="{prompt_utils_file}"
|
||||
export LLM_TOOL_RAW_JSON="$tool_data"
|
||||
}
|
||||
|
||||
load_env() {
|
||||
|
||||
@@ -11,7 +11,7 @@ async function main(): Promise<void> {
|
||||
const toolData = parseRawData(rawData);
|
||||
|
||||
const rootDir = "{root_dir}";
|
||||
setupEnv(rootDir, rawData);
|
||||
setupEnv(rootDir);
|
||||
|
||||
const toolPath = "{tool_path}.ts";
|
||||
await run(toolPath, "run", toolData);
|
||||
@@ -45,12 +45,11 @@ function parseArgv(): string {
|
||||
return toolData;
|
||||
}
|
||||
|
||||
function setupEnv(rootDir: string, rawData: string): void {
|
||||
function setupEnv(rootDir: string): void {
|
||||
loadEnv(join(rootDir, ".env"));
|
||||
process.env["LLM_ROOT_DIR"] = rootDir;
|
||||
process.env["LLM_TOOL_NAME"] = "{function_name}";
|
||||
process.env["LLM_TOOL_CACHE_DIR"] = join(rootDir, "cache", "{function_name}");
|
||||
process.env["LLM_TOOL_RAW_JSON"] = rawData;
|
||||
}
|
||||
|
||||
function loadEnv(filePath: string): void {
|
||||
|
||||
@@ -10,9 +10,6 @@ set -e
|
||||
source "$LLM_PROMPT_UTILS_FILE"
|
||||
|
||||
main() {
|
||||
# shellcheck disable=SC2154
|
||||
argc_command="$(jq -r '.command' <<< "$LLM_TOOL_RAW_JSON")"
|
||||
|
||||
guard_operation
|
||||
local script
|
||||
script="$(mktemp)"
|
||||
@@ -20,5 +17,5 @@ main() {
|
||||
trap "rm -f '$script'" EXIT
|
||||
# shellcheck disable=SC2154
|
||||
printf '%s\n' "$argc_command" > "$script"
|
||||
bash -e -o pipefail "$script" >> "$LLM_OUTPUT"
|
||||
bash "$script" >> "$LLM_OUTPUT"
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ source "$LLM_PROMPT_UTILS_FILE"
|
||||
|
||||
# shellcheck disable=SC2154
|
||||
main() {
|
||||
argc_code="$(jq -r '.code' <<< "$LLM_TOOL_RAW_JSON")"
|
||||
|
||||
if ! grep -qi '^select' <<<"$argc_code"; then
|
||||
guard_operation ""
|
||||
fi
|
||||
|
||||
@@ -28,8 +28,6 @@ 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'
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# @describe Apply a unified-diff patch to a file at the specified path. Use this for editing an existing file. It's the
|
||||
# PREFERRED way to modify a file. Prefer this over fs_write whenever the file already exists: it sends less data,
|
||||
# preserves unchanged content automatically, and is less prone to accidental data loss from full rewrites.
|
||||
# Use fs_write only when you are creating a new file or doing a complete rewrite where most of the content changes.
|
||||
# @describe Apply a patch to a file at the specified path.
|
||||
# This can be used to edit a file without having to rewrite the whole file.
|
||||
|
||||
# @option --path! The path of the file to apply the patch to
|
||||
# @option --contents! The patch to apply to the file
|
||||
@@ -16,9 +14,6 @@ source "$LLM_PROMPT_UTILS_FILE"
|
||||
|
||||
# shellcheck disable=SC2154
|
||||
main() {
|
||||
argc_contents="$(jq -r '.contents' <<< "$LLM_TOOL_RAW_JSON")"
|
||||
argc_path="$(jq -r '.path' <<< "$LLM_TOOL_RAW_JSON")"
|
||||
|
||||
if [[ ! -f "$argc_path" ]]; then
|
||||
error "Unable to find the specified file: $argc_path"
|
||||
exit 1
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# @describe Write the FULL file contents to a file at the specified path. Use this for NEW files or COMPLETE rewrites
|
||||
# only. For editing an existing file, prefer fs_patch. It's a surgical edit that preserves unchanged content, requires
|
||||
# sending less data, and is less prone to accidental data loss.
|
||||
# @describe Write the full file contents to a file at the specified path.
|
||||
|
||||
# @option --path! The path of the file to write to
|
||||
# @option --contents! The full contents to write to the file
|
||||
@@ -15,9 +13,6 @@ source "$LLM_PROMPT_UTILS_FILE"
|
||||
|
||||
# shellcheck disable=SC2154
|
||||
main() {
|
||||
argc_contents="$(jq -r '.contents' <<< "$LLM_TOOL_RAW_JSON")"
|
||||
argc_path="$(jq -r '.path' <<< "$LLM_TOOL_RAW_JSON")"
|
||||
|
||||
if [[ -f "$argc_path" ]]; then
|
||||
printf "%s" "$argc_contents" | git diff --no-index "$argc_path" - || true
|
||||
guard_operation "Apply changes?"
|
||||
|
||||
@@ -14,10 +14,6 @@ set -e
|
||||
|
||||
# shellcheck disable=SC2154
|
||||
main() {
|
||||
argc_recipient="$(jq -r '.recipient' <<< "$LLM_TOOL_RAW_JSON")"
|
||||
argc_subject="$(jq -r '.subject' <<< "$LLM_TOOL_RAW_JSON")"
|
||||
argc_body="$(jq -r '.body' <<< "$LLM_TOOL_RAW_JSON")"
|
||||
|
||||
sender_name="${EMAIL_SENDER_NAME:-$(echo "$EMAIL_SMTP_USER" | awk -F'@' '{print $1}')}"
|
||||
printf "%s\n" "From: $sender_name <$EMAIL_SMTP_USER>
|
||||
To: $argc_recipient
|
||||
|
||||
@@ -507,9 +507,7 @@ open_link() {
|
||||
|
||||
guard_operation() {
|
||||
if [[ -z "$AUTO_CONFIRM" && -z "$LLM_AGENT_VAR_AUTO_CONFIRM" ]]; then
|
||||
# 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)"
|
||||
ans="$(confirm "${1:-Are you sure you want to continue?}")"
|
||||
|
||||
if [[ "$ans" == 0 ]]; then
|
||||
error "Operation aborted!" 2>&1
|
||||
@@ -659,8 +657,7 @@ guard_path() {
|
||||
confirmation_prompt="$2"
|
||||
|
||||
if [[ ! "$path" == "$(pwd)"* && -z "$AUTO_CONFIRM" && -z "$LLM_AGENT_VAR_AUTO_CONFIRM" ]]; then
|
||||
# 2>/dev/tty: see guard_operation — prevents prompt text leaking via captured stderr.
|
||||
ans="$(confirm "$confirmation_prompt" 2>/dev/tty)"
|
||||
ans="$(confirm "$confirmation_prompt")"
|
||||
|
||||
if [[ "$ans" == 0 ]]; then
|
||||
error "Operation aborted!" >&2
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
---
|
||||
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,6 +1,3 @@
|
||||
---
|
||||
skills_enabled: false
|
||||
---
|
||||
Create a concise, 3-6 word title.
|
||||
|
||||
**Notes**:
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
---
|
||||
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,6 +1,3 @@
|
||||
---
|
||||
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,10 +48,6 @@ 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.
|
||||
|
||||
+3
-24
@@ -82,14 +82,7 @@ vault_password_file: null # Path to a file containing the password for th
|
||||
function_calling_support: true # Enables or disables function calling (Globally).
|
||||
mapping_tools: # Alias for a tool or toolset
|
||||
fs: 'fs_cat,fs_ls,fs_mkdir,fs_rm,fs_write,fs_read,fs_glob,fs_grep'
|
||||
enabled_tools: null # Which tools to enable by default.
|
||||
# Accepts either a YAML list or a comma-separated string. Use 'all' to enable everything.
|
||||
# Example (list form):
|
||||
# enabled_tools:
|
||||
# - fs
|
||||
# - web_search_coyote
|
||||
# Example (comma-separated form):
|
||||
# enabled_tools: fs,web_search_coyote
|
||||
enabled_tools: null # Which tools to enable by default. (e.g. 'fs,web_search_coyote')
|
||||
visible_tools: # Which tools are visible to be compiled (and are thus able to be defined in 'enabled_tools')
|
||||
# - demo_py.py
|
||||
# - demo_sh.sh
|
||||
@@ -125,14 +118,7 @@ visible_tools: # Which tools are visible to be compiled (and a
|
||||
mcp_server_support: true # Enables or disables MCP servers (globally).
|
||||
mapping_mcp_servers: # Alias for an MCP server or set of servers
|
||||
git: github,gitmcp
|
||||
enabled_mcp_servers: null # Which MCP servers to enable by default.
|
||||
# Accepts either a YAML list or a comma-separated string. Use 'all' to enable everything.
|
||||
# Example (list form):
|
||||
# enabled_mcp_servers:
|
||||
# - github
|
||||
# - slack
|
||||
# Example (comma-separated form):
|
||||
# enabled_mcp_servers: github,slack,ddg-search
|
||||
enabled_mcp_servers: null # Which MCP servers to enable by default (e.g. 'github,slack,ddg-search')
|
||||
|
||||
# ---- Skills ----
|
||||
# Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation.
|
||||
@@ -145,17 +131,10 @@ visible_skills: # The universe of skills allowed to be enabled
|
||||
- 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):
|
||||
# Example: only expose two skills in the bare REPL.
|
||||
# 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.
|
||||
|
||||
+4
-13
@@ -8,21 +8,12 @@ name: <role-name> # The name of the role
|
||||
model: openai:gpt-4o # The model to use for this role
|
||||
temperature: 0.2 # The temperature to use for this role when querying the model
|
||||
top_p: 0 # The top_p to use for this role when querying the model
|
||||
enabled_tools: # Tools to enable for this role. Accepts a YAML list (preferred)
|
||||
- fs_ls # or a comma-separated string (e.g. `enabled_tools: fs_ls,fs_cat`).
|
||||
- fs_cat # Use `all` to enable every visible tool.
|
||||
enabled_mcp_servers: # MCP servers to enable for this role. Accepts a YAML list (preferred)
|
||||
- github # or a comma-separated string (e.g. `enabled_mcp_servers: github,gitmcp`).
|
||||
- gitmcp # Use `all` to enable every configured MCP server.
|
||||
enabled_tools: fs_ls,fs_cat # A comma-separated list of tools to enable for this role
|
||||
enabled_mcp_servers: github,gitmcp # A comma-separated list of MCP servers to enable for this role
|
||||
skills_enabled: true # Master switch for skills in this role (default: inherit from global).
|
||||
# Skills also require `function_calling_support: true` in the global config.
|
||||
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)
|
||||
|
||||
enabled_skills: git-master,ai-slop-remover # Comma-separated list of skills available when this role is active.
|
||||
# Must be a subset of global `visible_skills`. Omit to inherit the global default.
|
||||
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,9 +63,6 @@ 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"
|
||||
@@ -176,12 +173,8 @@ 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,62 +3,6 @@
|
||||
# - 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
|
||||
@@ -1596,55 +1540,6 @@
|
||||
# - 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
|
||||
|
||||
@@ -137,8 +137,7 @@ 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) => match Vault::init(&app_config) {
|
||||
Ok(vault) => vault
|
||||
Ok(app_config) => Vault::init(&app_config)
|
||||
.list_secrets(false)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -146,7 +145,5 @@ pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||
.map(CompletionCandidate::new)
|
||||
.collect(),
|
||||
Err(_) => vec![],
|
||||
},
|
||||
Err(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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)]
|
||||
@@ -164,18 +163,6 @@ 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() {
|
||||
@@ -336,21 +323,6 @@ 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,9 +354,7 @@ 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()
|
||||
.replace("_", "-");
|
||||
let env_name = format!("{client}_{key}").to_ascii_uppercase();
|
||||
let required = std::env::var(&env_name).is_err();
|
||||
let value = if !is_secret {
|
||||
prompt_input_string(desc, required, *help_message)?
|
||||
|
||||
+11
-23
@@ -464,14 +464,6 @@ 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
|
||||
}
|
||||
@@ -556,12 +548,12 @@ impl RoleLike for Agent {
|
||||
self.config.top_p
|
||||
}
|
||||
|
||||
fn enabled_tools(&self) -> Option<Vec<String>> {
|
||||
fn enabled_tools(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn enabled_mcp_servers(&self) -> Option<Vec<String>> {
|
||||
Some(self.config.mcp_servers.clone())
|
||||
fn enabled_mcp_servers(&self) -> Option<String> {
|
||||
self.config.mcp_servers.clone().join(",").into()
|
||||
}
|
||||
|
||||
fn set_model(&mut self, model: Model) {
|
||||
@@ -577,14 +569,15 @@ impl RoleLike for Agent {
|
||||
self.config.top_p = value;
|
||||
}
|
||||
|
||||
fn set_enabled_tools(&mut self, value: Option<Vec<String>>) {
|
||||
fn set_enabled_tools(&mut self, value: Option<String>) {
|
||||
match value {
|
||||
Some(tools) => {
|
||||
self.config.global_tools = tools
|
||||
.into_iter()
|
||||
let tools = tools
|
||||
.split(',')
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
self.config.global_tools = tools;
|
||||
}
|
||||
None => {
|
||||
self.config.global_tools.clear();
|
||||
@@ -592,14 +585,15 @@ impl RoleLike for Agent {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>) {
|
||||
fn set_enabled_mcp_servers(&mut self, value: Option<String>) {
|
||||
match value {
|
||||
Some(servers) => {
|
||||
self.config.mcp_servers = servers
|
||||
.into_iter()
|
||||
let servers = servers
|
||||
.split(',')
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
self.config.mcp_servers = servers;
|
||||
}
|
||||
None => {
|
||||
self.config.mcp_servers.clear();
|
||||
@@ -633,10 +627,6 @@ 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)]
|
||||
@@ -716,8 +706,6 @@ 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(),
|
||||
|
||||
+10
-37
@@ -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, bail};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use gman::providers::SupportedProvider;
|
||||
use indexmap::IndexMap;
|
||||
use serde::Deserialize;
|
||||
@@ -34,26 +34,21 @@ pub struct AppConfig {
|
||||
|
||||
pub function_calling_support: bool,
|
||||
pub mapping_tools: IndexMap<String, String>,
|
||||
#[serde(default, deserialize_with = "super::deserialize_csv_or_vec")]
|
||||
pub enabled_tools: Option<Vec<String>>,
|
||||
pub enabled_tools: Option<String>,
|
||||
pub visible_tools: Option<Vec<String>>,
|
||||
|
||||
pub skills_enabled: bool,
|
||||
#[serde(default, deserialize_with = "super::deserialize_csv_or_vec")]
|
||||
pub enabled_skills: Option<Vec<String>>,
|
||||
pub enabled_skills: Option<String>,
|
||||
pub visible_skills: Option<Vec<String>>,
|
||||
|
||||
pub mcp_server_support: bool,
|
||||
pub mapping_mcp_servers: IndexMap<String, String>,
|
||||
#[serde(default, deserialize_with = "super::deserialize_csv_or_vec")]
|
||||
pub enabled_mcp_servers: Option<Vec<String>>,
|
||||
pub enabled_mcp_servers: Option<String>,
|
||||
|
||||
pub auto_continue: bool,
|
||||
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>,
|
||||
@@ -120,8 +115,6 @@ 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,
|
||||
@@ -189,8 +182,6 @@ 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,
|
||||
@@ -222,7 +213,6 @@ 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)?;
|
||||
}
|
||||
@@ -232,28 +222,11 @@ 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() {
|
||||
bail!("No available model");
|
||||
anyhow::bail!("No available model");
|
||||
}
|
||||
self.model_id = models[0].id();
|
||||
}
|
||||
@@ -419,7 +392,7 @@ impl AppConfig {
|
||||
self.mapping_tools = v;
|
||||
}
|
||||
if let Some(v) = super::read_env_value::<String>(&get_env_name("enabled_tools")) {
|
||||
self.enabled_tools = v.map(|raw| super::csv_to_vec(&raw));
|
||||
self.enabled_tools = v;
|
||||
}
|
||||
|
||||
if let Some(Some(v)) = super::read_env_bool(&get_env_name("skills_enabled")) {
|
||||
@@ -427,7 +400,7 @@ impl AppConfig {
|
||||
}
|
||||
|
||||
if let Some(v) = super::read_env_value::<String>(&get_env_name("enabled_skills")) {
|
||||
self.enabled_skills = v.map(|raw| super::csv_to_vec(&raw));
|
||||
self.enabled_skills = v;
|
||||
}
|
||||
|
||||
if let Some(Some(v)) = super::read_env_bool(&get_env_name("mcp_server_support")) {
|
||||
@@ -439,7 +412,7 @@ impl AppConfig {
|
||||
self.mapping_mcp_servers = v;
|
||||
}
|
||||
if let Some(v) = super::read_env_value::<String>(&get_env_name("enabled_mcp_servers")) {
|
||||
self.enabled_mcp_servers = v.map(|raw| super::csv_to_vec(&raw));
|
||||
self.enabled_mcp_servers = v;
|
||||
}
|
||||
|
||||
if let Some(v) = super::read_env_value::<String>(&get_env_name("repl_prelude")) {
|
||||
@@ -541,12 +514,12 @@ impl AppConfig {
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn set_enabled_tools_default(&mut self, value: Option<Vec<String>>) {
|
||||
pub fn set_enabled_tools_default(&mut self, value: Option<String>) {
|
||||
self.enabled_tools = value;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn set_enabled_mcp_servers_default(&mut self, value: Option<Vec<String>>) {
|
||||
pub fn set_enabled_mcp_servers_default(&mut self, value: Option<String>) {
|
||||
self.enabled_mcp_servers = value;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+31
-31
@@ -38,10 +38,10 @@ pub struct Input {
|
||||
}
|
||||
|
||||
impl Input {
|
||||
pub fn from_str(ctx: &RequestContext, text: &str, role: Option<Role>) -> Result<Self> {
|
||||
let (role, with_session, with_agent) = resolve_role(ctx, role)?;
|
||||
pub fn from_str(ctx: &RequestContext, text: &str, role: Option<Role>) -> Self {
|
||||
let (role, with_session, with_agent) = resolve_role(ctx, role);
|
||||
let captured = capture_input_config(ctx, &role);
|
||||
Ok(Self {
|
||||
Self {
|
||||
app_config: Arc::clone(&ctx.app.config),
|
||||
stream_enabled: captured.stream_enabled,
|
||||
session: captured.session,
|
||||
@@ -60,7 +60,7 @@ impl Input {
|
||||
rag_name: None,
|
||||
with_session,
|
||||
with_agent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn from_files(
|
||||
@@ -111,7 +111,7 @@ impl Input {
|
||||
));
|
||||
}
|
||||
}
|
||||
let (role, with_session, with_agent) = resolve_role(ctx, role)?;
|
||||
let (role, with_session, with_agent) = resolve_role(ctx, role);
|
||||
let captured = capture_input_config(ctx, &role);
|
||||
Ok(Self {
|
||||
app_config: Arc::clone(&ctx.app.config),
|
||||
@@ -398,14 +398,14 @@ impl Input {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_role(ctx: &RequestContext, role: Option<Role>) -> Result<(Role, bool, bool)> {
|
||||
fn resolve_role(ctx: &RequestContext, role: Option<Role>) -> (Role, bool, bool) {
|
||||
match role {
|
||||
Some(v) => Ok((v, false, false)),
|
||||
None => Ok((
|
||||
ctx.extract_role(ctx.app.config.as_ref())?,
|
||||
Some(v) => (v, false, false),
|
||||
None => (
|
||||
ctx.extract_role(ctx.app.config.as_ref()),
|
||||
ctx.session.is_some(),
|
||||
ctx.agent.is_some(),
|
||||
)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,7 +600,7 @@ mod tests {
|
||||
fn resolve_role_with_explicit_role() {
|
||||
let ctx = create_test_ctx();
|
||||
let role = Role::new("custom", "be helpful");
|
||||
let (resolved, with_session, with_agent) = resolve_role(&ctx, Some(role)).unwrap();
|
||||
let (resolved, with_session, with_agent) = resolve_role(&ctx, Some(role));
|
||||
assert_eq!(resolved.name(), "custom");
|
||||
assert!(!with_session);
|
||||
assert!(!with_agent);
|
||||
@@ -609,7 +609,7 @@ mod tests {
|
||||
#[test]
|
||||
fn resolve_role_without_role_no_session_no_agent() {
|
||||
let ctx = create_test_ctx();
|
||||
let (resolved, with_session, with_agent) = resolve_role(&ctx, None).unwrap();
|
||||
let (resolved, with_session, with_agent) = resolve_role(&ctx, None);
|
||||
assert_eq!(resolved.name(), "");
|
||||
assert!(!with_session);
|
||||
assert!(!with_agent);
|
||||
@@ -619,7 +619,7 @@ mod tests {
|
||||
fn resolve_role_without_role_with_session() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.session = Some(Session::default());
|
||||
let (_resolved, with_session, with_agent) = resolve_role(&ctx, None).unwrap();
|
||||
let (_resolved, with_session, with_agent) = resolve_role(&ctx, None);
|
||||
assert!(with_session);
|
||||
assert!(!with_agent);
|
||||
}
|
||||
@@ -629,7 +629,7 @@ mod tests {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.session = Some(Session::default());
|
||||
let role = Role::new("explicit", "prompt");
|
||||
let (_resolved, with_session, _with_agent) = resolve_role(&ctx, Some(role)).unwrap();
|
||||
let (_resolved, with_session, _with_agent) = resolve_role(&ctx, Some(role));
|
||||
assert!(!with_session);
|
||||
}
|
||||
|
||||
@@ -695,7 +695,7 @@ mod tests {
|
||||
#[test]
|
||||
fn input_from_str_captures_text() {
|
||||
let ctx = create_test_ctx();
|
||||
let input = Input::from_str(&ctx, "hello world", None).unwrap();
|
||||
let input = Input::from_str(&ctx, "hello world", None);
|
||||
assert_eq!(input.text(), "hello world");
|
||||
}
|
||||
|
||||
@@ -703,7 +703,7 @@ mod tests {
|
||||
fn input_from_str_with_explicit_role() {
|
||||
let ctx = create_test_ctx();
|
||||
let role = Role::new("pirate", "you are a pirate");
|
||||
let input = Input::from_str(&ctx, "ahoy", Some(role)).unwrap();
|
||||
let input = Input::from_str(&ctx, "ahoy", Some(role));
|
||||
assert_eq!(input.role().name(), "pirate");
|
||||
assert!(!input.with_agent());
|
||||
}
|
||||
@@ -715,28 +715,28 @@ mod tests {
|
||||
config.stream = false;
|
||||
state.config = Arc::new(config);
|
||||
let ctx = RequestContext::new(Arc::new(state), WorkingMode::Cmd);
|
||||
let input = Input::from_str(&ctx, "test", None).unwrap();
|
||||
let input = Input::from_str(&ctx, "test", None);
|
||||
assert!(!input.stream_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_is_empty_with_no_text_and_no_medias() {
|
||||
let ctx = create_test_ctx();
|
||||
let input = Input::from_str(&ctx, "", None).unwrap();
|
||||
let input = Input::from_str(&ctx, "", None);
|
||||
assert!(input.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_is_not_empty_with_text() {
|
||||
let ctx = create_test_ctx();
|
||||
let input = Input::from_str(&ctx, "hello", None).unwrap();
|
||||
let input = Input::from_str(&ctx, "hello", None);
|
||||
assert!(!input.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_set_text_changes_text() {
|
||||
let ctx = create_test_ctx();
|
||||
let mut input = Input::from_str(&ctx, "original", None).unwrap();
|
||||
let mut input = Input::from_str(&ctx, "original", None);
|
||||
input.set_text("modified".to_string());
|
||||
assert_eq!(input.text(), "modified");
|
||||
}
|
||||
@@ -744,7 +744,7 @@ mod tests {
|
||||
#[test]
|
||||
fn input_text_returns_patched_when_set() {
|
||||
let ctx = create_test_ctx();
|
||||
let mut input = Input::from_str(&ctx, "original", None).unwrap();
|
||||
let mut input = Input::from_str(&ctx, "original", None);
|
||||
input.patched_text = Some("patched".to_string());
|
||||
assert_eq!(input.text(), "patched");
|
||||
}
|
||||
@@ -752,7 +752,7 @@ mod tests {
|
||||
#[test]
|
||||
fn input_clear_patch_restores_original() {
|
||||
let ctx = create_test_ctx();
|
||||
let mut input = Input::from_str(&ctx, "original", None).unwrap();
|
||||
let mut input = Input::from_str(&ctx, "original", None);
|
||||
input.patched_text = Some("patched".to_string());
|
||||
input.clear_patch();
|
||||
assert_eq!(input.text(), "original");
|
||||
@@ -761,7 +761,7 @@ mod tests {
|
||||
#[test]
|
||||
fn input_set_continue_output_accumulates() {
|
||||
let ctx = create_test_ctx();
|
||||
let mut input = Input::from_str(&ctx, "test", None).unwrap();
|
||||
let mut input = Input::from_str(&ctx, "test", None);
|
||||
assert!(input.continue_output().is_none());
|
||||
input.set_continue_output("first ");
|
||||
assert_eq!(input.continue_output(), Some("first "));
|
||||
@@ -772,7 +772,7 @@ mod tests {
|
||||
#[test]
|
||||
fn input_set_regenerate_sets_flag_and_clears_tool_calls() {
|
||||
let ctx = create_test_ctx();
|
||||
let mut input = Input::from_str(&ctx, "test", None).unwrap();
|
||||
let mut input = Input::from_str(&ctx, "test", None);
|
||||
let role = input.role().clone();
|
||||
assert!(!input.regenerate());
|
||||
input.set_regenerate(role);
|
||||
@@ -784,7 +784,7 @@ mod tests {
|
||||
fn input_summary_truncates_long_text() {
|
||||
let ctx = create_test_ctx();
|
||||
let long_text = "a".repeat(200);
|
||||
let input = Input::from_str(&ctx, &long_text, None).unwrap();
|
||||
let input = Input::from_str(&ctx, &long_text, None);
|
||||
let summary = input.summary();
|
||||
assert!(summary.len() < 200);
|
||||
assert!(summary.ends_with("..."));
|
||||
@@ -793,35 +793,35 @@ mod tests {
|
||||
#[test]
|
||||
fn input_summary_preserves_short_text() {
|
||||
let ctx = create_test_ctx();
|
||||
let input = Input::from_str(&ctx, "short", None).unwrap();
|
||||
let input = Input::from_str(&ctx, "short", None);
|
||||
assert_eq!(input.summary(), "short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_raw_with_no_files() {
|
||||
let ctx = create_test_ctx();
|
||||
let input = Input::from_str(&ctx, "hello", None).unwrap();
|
||||
let input = Input::from_str(&ctx, "hello", None);
|
||||
assert_eq!(input.raw(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_render_with_no_medias() {
|
||||
let ctx = create_test_ctx();
|
||||
let input = Input::from_str(&ctx, "hello", None).unwrap();
|
||||
let input = Input::from_str(&ctx, "hello", None);
|
||||
assert_eq!(input.render(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_with_agent_false_when_no_agent() {
|
||||
let ctx = create_test_ctx();
|
||||
let input = Input::from_str(&ctx, "test", None).unwrap();
|
||||
let input = Input::from_str(&ctx, "test", None);
|
||||
assert!(!input.with_agent());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_session_returns_none_when_with_session_false() {
|
||||
let ctx = create_test_ctx();
|
||||
let input = Input::from_str(&ctx, "test", Some(Role::new("r", "p"))).unwrap();
|
||||
let input = Input::from_str(&ctx, "test", Some(Role::new("r", "p")));
|
||||
let session = Some(Session::default());
|
||||
assert!(input.session(&session).is_none());
|
||||
}
|
||||
@@ -830,7 +830,7 @@ mod tests {
|
||||
fn input_session_returns_some_when_with_session_true() {
|
||||
let mut ctx = create_test_ctx();
|
||||
ctx.session = Some(Session::default());
|
||||
let input = Input::from_str(&ctx, "test", None).unwrap();
|
||||
let input = Input::from_str(&ctx, "test", None);
|
||||
let session = Some(Session::default());
|
||||
assert!(input.session(&session).is_some());
|
||||
}
|
||||
|
||||
+10
-107
@@ -1,3 +1,10 @@
|
||||
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;
|
||||
@@ -5,13 +12,6 @@ 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,26 +418,6 @@ 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 {
|
||||
@@ -751,21 +731,8 @@ 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 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 vault = Vault::init_bare();
|
||||
let (_parsed, missing) = interpolate_secrets(&serialized, &vault)?;
|
||||
let mut deduped: Vec<String> = Vec::new();
|
||||
for s in missing {
|
||||
if !deduped.contains(&s) {
|
||||
@@ -893,7 +860,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();
|
||||
@@ -947,62 +914,6 @@ 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() {
|
||||
@@ -1342,9 +1253,7 @@ 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");
|
||||
@@ -1361,9 +1270,7 @@ 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");
|
||||
@@ -1429,9 +1336,7 @@ 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");
|
||||
@@ -1447,9 +1352,7 @@ 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");
|
||||
|
||||
@@ -29,12 +29,12 @@ pub async fn macro_execute(
|
||||
let variables = macro_value
|
||||
.resolve_variables(&new_args)
|
||||
.map_err(|err| anyhow!("{err}. Usage: {}", macro_value.usage(name)))?;
|
||||
let role = ctx.extract_role(ctx.app.config.as_ref())?;
|
||||
let role = ctx.extract_role(ctx.app.config.as_ref());
|
||||
let mut app_config = (*ctx.app.config).clone();
|
||||
app_config.temperature = role.temperature();
|
||||
app_config.top_p = role.top_p();
|
||||
app_config.enabled_tools = role.enabled_tools();
|
||||
app_config.enabled_mcp_servers = role.enabled_mcp_servers();
|
||||
app_config.enabled_tools = role.enabled_tools().clone();
|
||||
app_config.enabled_mcp_servers = role.enabled_mcp_servers().clone();
|
||||
|
||||
let mut app_state = (*ctx.app).clone();
|
||||
app_state.config = Arc::new(app_config);
|
||||
|
||||
+7
-81
@@ -6,7 +6,7 @@ mod install_remote;
|
||||
mod macros;
|
||||
mod mcp_factory;
|
||||
pub(crate) mod paths;
|
||||
pub(crate) mod prompts;
|
||||
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, should_inject_skill_instructions};
|
||||
pub use self::request_context::{RenderMode, RequestContext};
|
||||
pub use self::role::{
|
||||
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
|
||||
};
|
||||
@@ -196,26 +196,21 @@ pub struct Config {
|
||||
|
||||
pub function_calling_support: bool,
|
||||
pub mapping_tools: IndexMap<String, String>,
|
||||
#[serde(default, deserialize_with = "deserialize_csv_or_vec")]
|
||||
pub enabled_tools: Option<Vec<String>>,
|
||||
pub enabled_tools: Option<String>,
|
||||
pub visible_tools: Option<Vec<String>>,
|
||||
|
||||
pub skills_enabled: bool,
|
||||
#[serde(default, deserialize_with = "deserialize_csv_or_vec")]
|
||||
pub enabled_skills: Option<Vec<String>>,
|
||||
pub enabled_skills: Option<String>,
|
||||
pub visible_skills: Option<Vec<String>>,
|
||||
|
||||
pub mcp_server_support: bool,
|
||||
pub mapping_mcp_servers: IndexMap<String, String>,
|
||||
#[serde(default, deserialize_with = "deserialize_csv_or_vec")]
|
||||
pub enabled_mcp_servers: Option<Vec<String>>,
|
||||
pub enabled_mcp_servers: Option<String>,
|
||||
|
||||
pub auto_continue: bool,
|
||||
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>,
|
||||
@@ -282,8 +277,6 @@ 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,
|
||||
@@ -491,10 +484,9 @@ impl Config {
|
||||
|
||||
let bootstrap_app = AppConfig {
|
||||
vault_password_file: config.vault_password_file.clone(),
|
||||
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!(
|
||||
@@ -689,7 +681,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::default_local(),
|
||||
None => Vault::init_bare(),
|
||||
Some(provider) => Vault {
|
||||
provider: provider.clone(),
|
||||
},
|
||||
@@ -791,72 +783,6 @@ where
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(super) fn csv_to_vec(raw: &str) -> Vec<String> {
|
||||
raw.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn deserialize_csv_or_vec<'de, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<Vec<String>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::{self, SeqAccess, Visitor};
|
||||
use std::fmt;
|
||||
|
||||
struct CsvOrVec;
|
||||
|
||||
impl<'de> Visitor<'de> for CsvOrVec {
|
||||
type Value = Option<Vec<String>>;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a comma-separated string, a list of strings, or null")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, value: &str) -> std::result::Result<Self::Value, E> {
|
||||
Ok(Some(csv_to_vec(value)))
|
||||
}
|
||||
|
||||
fn visit_string<E: de::Error>(self, value: String) -> std::result::Result<Self::Value, E> {
|
||||
Ok(Some(csv_to_vec(&value)))
|
||||
}
|
||||
|
||||
fn visit_none<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn visit_some<D2: serde::Deserializer<'de>>(
|
||||
self,
|
||||
deserializer: D2,
|
||||
) -> std::result::Result<Self::Value, D2::Error> {
|
||||
deserializer.deserialize_any(self)
|
||||
}
|
||||
|
||||
fn visit_unit<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn visit_seq<A: SeqAccess<'de>>(
|
||||
self,
|
||||
mut seq: A,
|
||||
) -> std::result::Result<Self::Value, A::Error> {
|
||||
let mut vec = Vec::new();
|
||||
while let Some(item) = seq.next_element::<String>()? {
|
||||
let trimmed = item.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
vec.push(trimmed);
|
||||
}
|
||||
}
|
||||
Ok(Some(vec))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_option(CsvOrVec)
|
||||
}
|
||||
|
||||
fn read_env_bool(key: &str) -> Option<Option<bool>> {
|
||||
let value = env::var(key).ok()?;
|
||||
Some(parse_bool(&value))
|
||||
|
||||
@@ -80,19 +80,6 @@ pub fn skill_file(name: &str) -> PathBuf {
|
||||
skill_dir(name).join("SKILL.md")
|
||||
}
|
||||
|
||||
pub fn validate_skill_name(name: &str) -> Result<()> {
|
||||
if name.is_empty() {
|
||||
bail!("Skill name cannot be empty");
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
bail!("Invalid skill name '{name}': only letters, digits, '-', and '_' are allowed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn macros_dir() -> PathBuf {
|
||||
match env::var(get_env_name("macros_dir")) {
|
||||
Ok(value) => PathBuf::from(value),
|
||||
@@ -270,7 +257,6 @@ 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());
|
||||
}
|
||||
@@ -300,84 +286,3 @@ pub fn local_models_override() -> Result<Vec<ProviderModels>> {
|
||||
}
|
||||
Ok(models_override.list)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{fs, time};
|
||||
|
||||
#[test]
|
||||
fn validate_skill_name_accepts_alphanumerics_and_dashes() {
|
||||
assert!(validate_skill_name("git-master").is_ok());
|
||||
assert!(validate_skill_name("code_review").is_ok());
|
||||
assert!(validate_skill_name("Skill1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_skill_name_rejects_empty() {
|
||||
let err = validate_skill_name("").unwrap_err();
|
||||
assert!(err.to_string().contains("cannot be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_skill_name_rejects_path_traversal() {
|
||||
for bad in ["../escape", "..", "foo/bar", "foo\\bar", "./hidden"] {
|
||||
let err = validate_skill_name(bad).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("Invalid skill name"),
|
||||
"expected rejection for {bad:?}, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_skill_name_rejects_other_special_chars() {
|
||||
for bad in ["with space", "null\0byte", "weird?char", "dot.name"] {
|
||||
assert!(
|
||||
validate_skill_name(bad).is_err(),
|
||||
"expected rejection for {bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_skill_returns_false_for_missing_paths() {
|
||||
for absent in ["definitely-not-installed-skill-xyz", "another-missing"] {
|
||||
assert!(
|
||||
!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,13 +1,5 @@
|
||||
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:
|
||||
|
||||
+73
-519
@@ -14,8 +14,8 @@ use super::{
|
||||
use super::{MessageContentToolCalls, prompts};
|
||||
use crate::client::{Model, ModelType, list_models};
|
||||
use crate::function::{
|
||||
FunctionDeclaration, Functions, ToolCallTracker, ToolResult, skill::SKILL_FUNCTION_PREFIX,
|
||||
user_interaction::USER_FUNCTION_PREFIX,
|
||||
FunctionDeclaration, Functions, ToolCallTracker, ToolResult,
|
||||
skill::SKILL_FUNCTION_PREFIX, user_interaction::USER_FUNCTION_PREFIX,
|
||||
};
|
||||
use crate::mcp::{
|
||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
||||
@@ -37,9 +37,7 @@ use gman::providers::SupportedProvider;
|
||||
use indexmap::IndexMap;
|
||||
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;
|
||||
@@ -54,20 +52,6 @@ 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]
|
||||
@@ -617,7 +601,7 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_role(&self, app: &AppConfig) -> Result<Role> {
|
||||
pub fn extract_role(&self, app: &AppConfig) -> Role {
|
||||
let mut role = if let Some(session) = self.session.as_ref() {
|
||||
session.to_role()
|
||||
} else if let Some(agent) = self.agent.as_ref() {
|
||||
@@ -643,66 +627,7 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
let policy = SkillPolicy::effective(
|
||||
app,
|
||||
self.role.as_ref(),
|
||||
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,
|
||||
}
|
||||
self.skill_registry.effective_role(&role)
|
||||
}
|
||||
|
||||
pub fn auto_continue_config(&self) -> AutoContinueConfig {
|
||||
@@ -775,7 +700,7 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_enabled_tools_on_role_like(&mut self, value: Option<Vec<String>>) -> bool {
|
||||
pub fn set_enabled_tools_on_role_like(&mut self, value: Option<String>) -> bool {
|
||||
match self.role_like_mut() {
|
||||
Some(role_like) => {
|
||||
role_like.set_enabled_tools(value);
|
||||
@@ -785,7 +710,7 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_enabled_mcp_servers_on_role_like(&mut self, value: Option<Vec<String>>) -> bool {
|
||||
pub fn set_enabled_mcp_servers_on_role_like(&mut self, value: Option<String>) -> bool {
|
||||
match self.role_like_mut() {
|
||||
Some(role_like) => {
|
||||
role_like.set_enabled_mcp_servers(value);
|
||||
@@ -919,7 +844,7 @@ impl RequestContext {
|
||||
Some(rag) => rag.get_config(),
|
||||
None => (app.rag_reranker_model.clone(), app.rag_top_k),
|
||||
};
|
||||
let role = self.extract_role(app)?;
|
||||
let role = self.extract_role(app);
|
||||
let mut items = vec![
|
||||
("model", role.model().id()),
|
||||
(
|
||||
@@ -929,11 +854,11 @@ impl RequestContext {
|
||||
("top_p", super::format_option_value(&role.top_p())),
|
||||
(
|
||||
"enabled_tools",
|
||||
super::format_option_value(&role.enabled_tools().map(|v| v.join(","))),
|
||||
super::format_option_value(&role.enabled_tools()),
|
||||
),
|
||||
(
|
||||
"enabled_mcp_servers",
|
||||
super::format_option_value(&role.enabled_mcp_servers().map(|v| v.join(","))),
|
||||
super::format_option_value(&role.enabled_mcp_servers()),
|
||||
),
|
||||
(
|
||||
"max_output_tokens",
|
||||
@@ -985,10 +910,7 @@ impl RequestContext {
|
||||
match &app.secrets_provider {
|
||||
None => {
|
||||
items.push(("secrets_provider", "local".to_string()));
|
||||
items.push((
|
||||
"vault_password_file",
|
||||
display_path(&app.vault_password_file()),
|
||||
));
|
||||
items.push(("vault_password_file", display_path(&app.vault_password_file())));
|
||||
}
|
||||
Some(provider) => {
|
||||
items.push(("secrets_provider", provider.to_string()));
|
||||
@@ -1092,10 +1014,7 @@ impl RequestContext {
|
||||
|
||||
pub fn generate_prompt_context(&self, app: &AppConfig) -> HashMap<&str, String> {
|
||||
let mut output = HashMap::new();
|
||||
let role = self.extract_role(app).unwrap_or_else(|err| {
|
||||
warn!("failed to compute effective role for prompt rendering: {err}");
|
||||
Role::default()
|
||||
});
|
||||
let role = self.extract_role(app);
|
||||
output.insert("model", role.model().id());
|
||||
output.insert("client_name", role.model().client_name().to_string());
|
||||
output.insert("model_name", role.model().name().to_string());
|
||||
@@ -1226,10 +1145,10 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
let mut tool_names: HashSet<String> = Default::default();
|
||||
if enabled_tools.iter().any(|s| s.trim() == "all") {
|
||||
if enabled_tools == "all" {
|
||||
tool_names.extend(declaration_names);
|
||||
} else {
|
||||
for item in enabled_tools.iter() {
|
||||
for item in enabled_tools.split(',') {
|
||||
let item = item.trim();
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
@@ -1275,8 +1194,7 @@ impl RequestContext {
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
(v.name.starts_with(USER_FUNCTION_PREFIX)
|
||||
|| (!matches!(role.skills_enabled(), Some(false))
|
||||
&& v.name.starts_with(SKILL_FUNCTION_PREFIX)))
|
||||
|| v.name.starts_with(SKILL_FUNCTION_PREFIX))
|
||||
&& !existing.contains(&v.name)
|
||||
})
|
||||
.cloned()
|
||||
@@ -1298,12 +1216,7 @@ impl RequestContext {
|
||||
.collect();
|
||||
|
||||
if let Some(ref tool_names) = role_filter {
|
||||
agent_functions.retain(|v| {
|
||||
tool_names.contains(&v.name)
|
||||
|| (!matches!(agent.skills_enabled(), Some(false))
|
||||
&& v.name.starts_with(SKILL_FUNCTION_PREFIX))
|
||||
|| v.name.starts_with(USER_FUNCTION_PREFIX)
|
||||
});
|
||||
agent_functions.retain(|v| tool_names.contains(&v.name));
|
||||
}
|
||||
|
||||
let tool_names: HashSet<String> = agent_functions
|
||||
@@ -1363,10 +1276,10 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
let mut server_names: HashSet<String> = Default::default();
|
||||
if enabled_mcp_servers.iter().any(|s| s.trim() == "all") {
|
||||
if enabled_mcp_servers == "all" {
|
||||
server_names.extend(mcp_declaration_names);
|
||||
} else {
|
||||
for item in enabled_mcp_servers.iter() {
|
||||
for item in enabled_mcp_servers.split(',') {
|
||||
let item = item.trim();
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
@@ -1776,7 +1689,7 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
let value = match key {
|
||||
"continuation_prompt" | "skill_instructions" => raw_value,
|
||||
"continuation_prompt" => raw_value,
|
||||
_ => {
|
||||
if raw_value.contains(char::is_whitespace) {
|
||||
bail!("Usage: .set <key> <value>. If value is null, unset key.");
|
||||
@@ -1798,49 +1711,14 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
"enabled_tools" => {
|
||||
let raw: Option<String> = super::parse_value(value)?;
|
||||
let parsed: Option<Vec<String>> = raw.map(|s| super::csv_to_vec(&s));
|
||||
if !self.set_enabled_tools_on_role_like(parsed.clone()) {
|
||||
self.update_app_config(|app| app.enabled_tools = parsed.clone());
|
||||
}
|
||||
}
|
||||
"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" => {
|
||||
let value: Option<bool> = super::parse_value(value)?;
|
||||
if let Some(session) = self.session.as_mut() {
|
||||
session.set_skills_enabled(value);
|
||||
} else {
|
||||
self.update_app_config(|app| app.skills_enabled = value.unwrap_or(true));
|
||||
let value = super::parse_value(value)?;
|
||||
if !self.set_enabled_tools_on_role_like(value.clone()) {
|
||||
self.update_app_config(|app| app.enabled_tools = value);
|
||||
}
|
||||
}
|
||||
"enabled_mcp_servers" => {
|
||||
let raw: Option<String> = super::parse_value(value)?;
|
||||
let parsed: Option<Vec<String>> = raw.map(|s| super::csv_to_vec(&s));
|
||||
if let Some(servers) = parsed.as_ref() {
|
||||
let value: Option<String> = super::parse_value(value)?;
|
||||
if let Some(servers) = value.as_ref() {
|
||||
let Some(mcp_config) = &self.app.mcp_config else {
|
||||
bail!(
|
||||
"No MCP servers are configured. Please configure MCP servers first before setting 'enabled_mcp_servers'."
|
||||
@@ -1852,7 +1730,7 @@ impl RequestContext {
|
||||
);
|
||||
}
|
||||
|
||||
if !servers.iter().all(|s| {
|
||||
if !servers.split(',').all(|s| {
|
||||
let server = s.trim();
|
||||
server == "all" || mcp_config.mcp_servers.contains_key(server)
|
||||
}) {
|
||||
@@ -1861,8 +1739,8 @@ impl RequestContext {
|
||||
);
|
||||
}
|
||||
}
|
||||
if !self.set_enabled_mcp_servers_on_role_like(parsed.clone()) {
|
||||
self.update_app_config(|app| app.enabled_mcp_servers = parsed.clone());
|
||||
if !self.set_enabled_mcp_servers_on_role_like(value.clone()) {
|
||||
self.update_app_config(|app| app.enabled_mcp_servers = value.clone());
|
||||
}
|
||||
if self.app.config.mcp_server_support {
|
||||
let app = Arc::clone(&self.app.config);
|
||||
@@ -1976,22 +1854,6 @@ 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(())
|
||||
@@ -2068,11 +1930,13 @@ impl RequestContext {
|
||||
super::map_completion_values(values)
|
||||
}
|
||||
".macro" => super::map_completion_values(paths::list_macros()),
|
||||
".skill" => super::map_completion_values(vec![
|
||||
".skill" => {
|
||||
super::map_completion_values(vec![
|
||||
"loaded".to_string(),
|
||||
"load".to_string(),
|
||||
"unload".to_string(),
|
||||
]),
|
||||
])
|
||||
}
|
||||
".starter" => match &self.agent {
|
||||
Some(agent) => agent
|
||||
.conversation_starters()
|
||||
@@ -2091,8 +1955,6 @@ impl RequestContext {
|
||||
"enabled_tools",
|
||||
"enabled_mcp_servers",
|
||||
"inject_todo_instructions",
|
||||
"inject_skill_instructions",
|
||||
"skill_instructions",
|
||||
"max_auto_continues",
|
||||
"save_session",
|
||||
"compression_threshold",
|
||||
@@ -2102,7 +1964,6 @@ impl RequestContext {
|
||||
"dry_run",
|
||||
"function_calling_support",
|
||||
"mcp_server_support",
|
||||
"skills_enabled",
|
||||
"stream",
|
||||
"save",
|
||||
"highlight",
|
||||
@@ -2201,14 +2062,6 @@ impl RequestContext {
|
||||
.collect()
|
||||
}
|
||||
"mcp_server_support" => super::complete_bool(app.mcp_server_support),
|
||||
"skills_enabled" => {
|
||||
let current = if let Some(session) = &self.session {
|
||||
session.skills_enabled()
|
||||
} else {
|
||||
Some(app.skills_enabled)
|
||||
};
|
||||
super::complete_option_bool(current)
|
||||
}
|
||||
"enabled_mcp_servers" => {
|
||||
let mut prefix = String::new();
|
||||
let mut ignores = HashSet::new();
|
||||
@@ -2259,11 +2112,6 @@ 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();
|
||||
@@ -2292,7 +2140,7 @@ impl RequestContext {
|
||||
async fn rebuild_tool_scope(
|
||||
&mut self,
|
||||
app: &AppConfig,
|
||||
enabled_mcp_servers: Option<Vec<String>>,
|
||||
enabled_mcp_servers: Option<String>,
|
||||
abort_signal: AbortSignal,
|
||||
) -> Result<()> {
|
||||
let policy = SkillPolicy::effective(
|
||||
@@ -2304,23 +2152,21 @@ impl RequestContext {
|
||||
|
||||
let enabled_mcp_servers = if policy.skills_enabled && app.mcp_server_support {
|
||||
let skill_mcps = self.skill_registry.loaded_mcp_servers();
|
||||
let has_all = enabled_mcp_servers
|
||||
.as_ref()
|
||||
.map(|v| v.iter().any(|s| s.trim() == "all"))
|
||||
.unwrap_or(false);
|
||||
if has_all || skill_mcps.is_empty() {
|
||||
enabled_mcp_servers
|
||||
} else {
|
||||
match (enabled_mcp_servers.as_deref(), skill_mcps.is_empty()) {
|
||||
(Some("all"), _) | (_, true) => enabled_mcp_servers,
|
||||
(base, false) => {
|
||||
let mut merged: BTreeSet<String> = skill_mcps;
|
||||
if let Some(servers) = &enabled_mcp_servers {
|
||||
for token in servers {
|
||||
if let Some(s) = base {
|
||||
for token in s.split(',') {
|
||||
let t = token.trim();
|
||||
if !t.is_empty() {
|
||||
merged.insert(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(merged.into_iter().collect())
|
||||
|
||||
Some(merged.into_iter().collect::<Vec<_>>().join(","))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enabled_mcp_servers
|
||||
@@ -2332,12 +2178,12 @@ impl RequestContext {
|
||||
&& let Some(mcp_config) = &self.app.mcp_config
|
||||
{
|
||||
let server_ids: Vec<String> = match &enabled_mcp_servers {
|
||||
Some(servers) if servers.iter().any(|s| s.trim() == "all") => {
|
||||
Some(servers) if servers == "all" => {
|
||||
mcp_config.mcp_servers.keys().cloned().collect()
|
||||
}
|
||||
Some(servers) => {
|
||||
let mut ids = Vec::new();
|
||||
for item in servers.iter().map(|s| s.trim()) {
|
||||
for item in servers.split(',').map(|s| s.trim()) {
|
||||
if mcp_config.mcp_servers.contains_key(item) {
|
||||
ids.push(item.to_string());
|
||||
} else if let Some(mapped) = app.mapping_mcp_servers.get(item) {
|
||||
@@ -2416,7 +2262,7 @@ impl RequestContext {
|
||||
if names.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(names.to_vec())
|
||||
Some(names.join(","))
|
||||
}
|
||||
} else if let Some(role) = &self.role {
|
||||
role.enabled_mcp_servers()
|
||||
@@ -2483,12 +2329,12 @@ impl RequestContext {
|
||||
format!("Failed to cleanup previous '{TEMP_SESSION_NAME}' session")
|
||||
})?;
|
||||
}
|
||||
session = Some(Session::new_from_ctx(self, app, TEMP_SESSION_NAME)?);
|
||||
session = Some(Session::new_from_ctx(self, app, TEMP_SESSION_NAME));
|
||||
}
|
||||
Some(name) => {
|
||||
let session_path = self.session_file(name);
|
||||
if !session_path.exists() {
|
||||
session = Some(Session::new_from_ctx(self, app, name)?);
|
||||
session = Some(Session::new_from_ctx(self, app, name));
|
||||
} else {
|
||||
session = Some(Session::load_from_ctx(self, app, name, &session_path)?);
|
||||
}
|
||||
@@ -2576,7 +2422,7 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
let mcp_servers = if app.mcp_server_support {
|
||||
(!agent.mcp_server_names().is_empty()).then(|| agent.mcp_server_names().to_vec())
|
||||
(!agent.mcp_server_names().is_empty()).then(|| agent.mcp_server_names().join(","))
|
||||
} else {
|
||||
if !agent.mcp_server_names().is_empty() {
|
||||
bail!(
|
||||
@@ -2703,7 +2549,6 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
pub fn upsert_skill(&self, app: &AppConfig, name: &str) -> Result<()> {
|
||||
paths::validate_skill_name(name)?;
|
||||
let path = paths::skill_file(name);
|
||||
ensure_parent_exists(&path)?;
|
||||
let is_new = !path.exists();
|
||||
@@ -2722,11 +2567,8 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
pub async fn load_skill_repl(&mut self, name: &str, abort_signal: AbortSignal) -> Result<()> {
|
||||
paths::validate_skill_name(name)?;
|
||||
if !self.app.config.function_calling_support {
|
||||
bail!(
|
||||
"Skills require function calling, which is disabled. Enable function calling in your config then try again."
|
||||
);
|
||||
bail!("Skills require function calling, which is disabled. Enable function calling in your config then try again.");
|
||||
}
|
||||
|
||||
if !paths::has_skill(name) {
|
||||
@@ -2754,7 +2596,7 @@ impl RequestContext {
|
||||
let skill = Skill::load(name)?;
|
||||
let needs_mcps = skill
|
||||
.enabled_mcp_servers()
|
||||
.map(|v| !v.is_empty())
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if needs_mcps && !self.app.config.mcp_server_support {
|
||||
@@ -2763,9 +2605,7 @@ impl RequestContext {
|
||||
|
||||
self.skill_registry.insert(skill)?;
|
||||
if let Err(e) = self.refresh_tool_scope(abort_signal).await {
|
||||
if let Err(unload_err) = self.skill_registry.unload(name) {
|
||||
warn!("Failed to unload skill '{name}' during error recovery: {unload_err}");
|
||||
}
|
||||
let _ = self.skill_registry.unload(name);
|
||||
bail!("Loaded skill '{name}' but failed to refresh tool scope: {e}");
|
||||
}
|
||||
|
||||
@@ -2774,15 +2614,10 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
pub async fn unload_skill_repl(&mut self, name: &str, abort_signal: AbortSignal) -> Result<()> {
|
||||
let skill = self.skill_registry.unload(name)?;
|
||||
self.skill_registry.unload(name)?;
|
||||
|
||||
if let Err(e) = self.refresh_tool_scope(abort_signal).await {
|
||||
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}");
|
||||
eprintln!("Warning: unloaded skill '{name}' but tool scope refresh failed: {e}");
|
||||
}
|
||||
|
||||
println!("✓ Unloaded skill '{name}'.");
|
||||
@@ -2868,13 +2703,13 @@ impl RequestContext {
|
||||
&self,
|
||||
app: &AppConfig,
|
||||
start_mcp_servers: bool,
|
||||
) -> Option<Vec<String>> {
|
||||
) -> Option<String> {
|
||||
if !start_mcp_servers || !app.mcp_server_support {
|
||||
return None;
|
||||
}
|
||||
if let Some(agent) = self.agent.as_ref() {
|
||||
return (!agent.mcp_server_names().is_empty())
|
||||
.then(|| agent.mcp_server_names().to_vec());
|
||||
.then(|| agent.mcp_server_names().join(","));
|
||||
}
|
||||
if let Some(session) = self.session.as_ref() {
|
||||
return session.enabled_mcp_servers();
|
||||
@@ -2914,7 +2749,7 @@ impl RequestContext {
|
||||
.summarization_prompt
|
||||
.clone()
|
||||
.unwrap_or_else(|| SUMMARIZATION_PROMPT.into());
|
||||
let input = Input::from_str(self, &prompt, None)?;
|
||||
let input = Input::from_str(self, &prompt, None);
|
||||
let summary = input.fetch_chat_text().await?;
|
||||
let summary_context_prompt = self
|
||||
.app
|
||||
@@ -2949,7 +2784,7 @@ impl RequestContext {
|
||||
None => bail!("No chat history"),
|
||||
};
|
||||
let role = self.retrieve_role(app, CREATE_TITLE_ROLE)?;
|
||||
let input = Input::from_str(self, &text, Some(role))?;
|
||||
let input = Input::from_str(self, &text, Some(role));
|
||||
let text = input.fetch_chat_text().await?;
|
||||
if let Some(session) = self.session.as_mut() {
|
||||
session.set_autoname(&text);
|
||||
@@ -3073,12 +2908,11 @@ mod tests {
|
||||
use super::super::mcp_factory::McpFactory;
|
||||
use super::*;
|
||||
use crate::config::AppState;
|
||||
use crate::function::{ToolCall, skill};
|
||||
use crate::function::ToolCall;
|
||||
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};
|
||||
@@ -3204,7 +3038,7 @@ mod tests {
|
||||
let app = ctx.app.config.clone();
|
||||
let role = Role::new("myrole", "my prompt");
|
||||
ctx.use_role_obj(role).unwrap();
|
||||
let extracted = ctx.extract_role(&app).unwrap();
|
||||
let extracted = ctx.extract_role(&app);
|
||||
assert_eq!(extracted.name(), "myrole");
|
||||
}
|
||||
|
||||
@@ -3212,112 +3046,10 @@ mod tests {
|
||||
fn extract_role_returns_default_when_nothing_active() {
|
||||
let ctx = create_test_ctx();
|
||||
let app = ctx.app.config.clone();
|
||||
let extracted = ctx.extract_role(&app).unwrap();
|
||||
let extracted = ctx.extract_role(&app);
|
||||
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();
|
||||
@@ -3470,7 +3202,7 @@ mod tests {
|
||||
let app = ctx.app.config.clone();
|
||||
let abort = utils::create_abort_signal();
|
||||
|
||||
run_async(ctx.rebuild_tool_scope(&app, Some(vec!["all".to_string()]), abort)).unwrap();
|
||||
run_async(ctx.rebuild_tool_scope(&app, Some("all".to_string()), abort)).unwrap();
|
||||
|
||||
assert!(ctx.tool_scope.mcp_runtime.is_empty());
|
||||
}
|
||||
@@ -3498,7 +3230,7 @@ mod tests {
|
||||
let app = ctx.app.config.clone();
|
||||
let abort = utils::create_abort_signal();
|
||||
|
||||
run_async(ctx.rebuild_tool_scope(&app, Some(vec!["all".to_string()]), abort)).unwrap();
|
||||
run_async(ctx.rebuild_tool_scope(&app, Some("all".to_string()), abort)).unwrap();
|
||||
|
||||
assert!(ctx.tool_scope.mcp_runtime.is_empty());
|
||||
}
|
||||
@@ -3606,7 +3338,7 @@ mod tests {
|
||||
};
|
||||
let ctx = RequestContext::new(app_state, WorkingMode::Cmd);
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec!["all".to_string()]));
|
||||
role.set_enabled_tools(Some("all".to_string()));
|
||||
assert!(ctx.select_functions(&role).is_none());
|
||||
}
|
||||
|
||||
@@ -3617,7 +3349,7 @@ mod tests {
|
||||
ctx.tool_scope.functions.append_user_interaction_functions();
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec!["all".to_string()]));
|
||||
role.set_enabled_tools(Some("all".to_string()));
|
||||
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
@@ -3631,10 +3363,7 @@ mod tests {
|
||||
ctx.tool_scope.functions.append_todo_functions();
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_tools(Some(vec![
|
||||
"todo__init".to_string(),
|
||||
"todo__add".to_string(),
|
||||
]));
|
||||
role.set_enabled_tools(Some("todo__init, todo__add".to_string()));
|
||||
|
||||
let fns = ctx.select_functions(&role).unwrap();
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
@@ -3643,182 +3372,6 @@ 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 = {
|
||||
@@ -3839,7 +3392,7 @@ mod tests {
|
||||
};
|
||||
let ctx = RequestContext::new(app_state, WorkingMode::Cmd);
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_mcp_servers(Some(vec!["all".to_string()]));
|
||||
role.set_enabled_mcp_servers(Some("all".to_string()));
|
||||
let result = ctx.select_enabled_mcp_servers(&role);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
@@ -3852,7 +3405,7 @@ mod tests {
|
||||
.append_mcp_meta_functions(vec!["github".into(), "slack".into()]);
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_mcp_servers(Some(vec!["all".to_string()]));
|
||||
role.set_enabled_mcp_servers(Some("all".to_string()));
|
||||
|
||||
let fns = ctx.select_enabled_mcp_servers(&role);
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
@@ -3869,7 +3422,7 @@ mod tests {
|
||||
.append_mcp_meta_functions(vec!["github".into(), "slack".into()]);
|
||||
|
||||
let mut role = Role::new("r", "p");
|
||||
role.set_enabled_mcp_servers(Some(vec!["github".to_string()]));
|
||||
role.set_enabled_mcp_servers(Some("github".to_string()));
|
||||
|
||||
let fns = ctx.select_enabled_mcp_servers(&role);
|
||||
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
|
||||
@@ -3981,7 +3534,7 @@ mod tests {
|
||||
#[test]
|
||||
fn discontinuous_last_message_sets_continuous_false() {
|
||||
let mut ctx = create_test_ctx();
|
||||
let input = Input::from_str(&ctx, "test", None).unwrap();
|
||||
let input = Input::from_str(&ctx, "test", None);
|
||||
ctx.last_message = Some(LastMessage::new(input, "reply".to_string()));
|
||||
assert!(ctx.last_message.as_ref().unwrap().continuous);
|
||||
ctx.discontinuous_last_message();
|
||||
@@ -3999,7 +3552,7 @@ mod tests {
|
||||
#[test]
|
||||
fn before_chat_completion_sets_last_message() {
|
||||
let mut ctx = create_test_ctx();
|
||||
let input = Input::from_str(&ctx, "hello", None).unwrap();
|
||||
let input = Input::from_str(&ctx, "hello", None);
|
||||
ctx.before_chat_completion(&input).unwrap();
|
||||
assert!(ctx.last_message.is_some());
|
||||
let lm = ctx.last_message.as_ref().unwrap();
|
||||
@@ -4023,7 +3576,7 @@ mod tests {
|
||||
ctx.skill_registry.insert(ephemeral).unwrap();
|
||||
ctx.skill_registry.insert(persistent).unwrap();
|
||||
|
||||
let input = Input::from_str(&ctx, "hello", None).unwrap();
|
||||
let input = Input::from_str(&ctx, "hello", None);
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
ctx.after_chat_completion(app.as_ref(), &input, "response", &[])
|
||||
.unwrap();
|
||||
@@ -4046,9 +3599,10 @@ mod tests {
|
||||
let ephemeral = Skill::new("ephemeral", "---\nauto_unload: true\n---\nbody");
|
||||
ctx.skill_registry.insert(ephemeral).unwrap();
|
||||
|
||||
let input = Input::from_str(&ctx, "hello", None).unwrap();
|
||||
let input = Input::from_str(&ctx, "hello", None);
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
let tool_result = ToolResult::new(crate::function::ToolCall::default(), json!({}));
|
||||
let tool_result =
|
||||
ToolResult::new(crate::function::ToolCall::default(), serde_json::json!({}));
|
||||
ctx.after_chat_completion(app.as_ref(), &input, "", &[tool_result])
|
||||
.unwrap();
|
||||
|
||||
@@ -4207,7 +3761,7 @@ mod tests {
|
||||
fn session_new_from_ctx_captures_state() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let ctx = create_test_ctx();
|
||||
let session = Session::new_from_ctx(&ctx, &ctx.app.config, "test-session").unwrap();
|
||||
let session = Session::new_from_ctx(&ctx, &ctx.app.config, "test-session");
|
||||
assert_eq!(session.name(), "test-session");
|
||||
assert!(session.is_empty());
|
||||
}
|
||||
@@ -4217,7 +3771,7 @@ mod tests {
|
||||
fn session_save_creates_file() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let ctx = create_test_ctx();
|
||||
let mut session = Session::new_from_ctx(&ctx, &ctx.app.config, "save-test").unwrap();
|
||||
let mut session = Session::new_from_ctx(&ctx, &ctx.app.config, "save-test");
|
||||
let session_path = ctx.session_file("save-test");
|
||||
ensure_parent_exists(&session_path).unwrap();
|
||||
|
||||
|
||||
+27
-95
@@ -28,13 +28,13 @@ pub trait RoleLike {
|
||||
fn model(&self) -> &Model;
|
||||
fn temperature(&self) -> Option<f64>;
|
||||
fn top_p(&self) -> Option<f64>;
|
||||
fn enabled_tools(&self) -> Option<Vec<String>>;
|
||||
fn enabled_mcp_servers(&self) -> Option<Vec<String>>;
|
||||
fn enabled_tools(&self) -> Option<String>;
|
||||
fn enabled_mcp_servers(&self) -> Option<String>;
|
||||
fn set_model(&mut self, model: Model);
|
||||
fn set_temperature(&mut self, value: Option<f64>);
|
||||
fn set_top_p(&mut self, value: Option<f64>);
|
||||
fn set_enabled_tools(&mut self, value: Option<Vec<String>>);
|
||||
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>);
|
||||
fn set_enabled_tools(&mut self, value: Option<String>);
|
||||
fn set_enabled_mcp_servers(&mut self, value: Option<String>);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -51,26 +51,14 @@ pub struct Role {
|
||||
temperature: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
top_p: Option<f64>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "super::deserialize_csv_or_vec"
|
||||
)]
|
||||
enabled_tools: Option<Vec<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "super::deserialize_csv_or_vec"
|
||||
)]
|
||||
enabled_mcp_servers: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled_tools: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled_mcp_servers: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
skills_enabled: Option<bool>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "super::deserialize_csv_or_vec"
|
||||
)]
|
||||
enabled_skills: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled_skills: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
auto_continue: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -79,10 +67,6 @@ 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,
|
||||
@@ -114,12 +98,12 @@ impl Role {
|
||||
"model" => role.model_id = value.as_str().map(|v| v.to_string()),
|
||||
"temperature" => role.temperature = value.as_f64(),
|
||||
"top_p" => role.top_p = value.as_f64(),
|
||||
"enabled_tools" => role.enabled_tools = parse_string_or_array(value),
|
||||
"enabled_tools" => role.enabled_tools = value.as_str().map(|v| v.to_string()),
|
||||
"enabled_mcp_servers" => {
|
||||
role.enabled_mcp_servers = parse_string_or_array(value)
|
||||
role.enabled_mcp_servers = value.as_str().map(|v| v.to_string())
|
||||
}
|
||||
"skills_enabled" => role.skills_enabled = value.as_bool(),
|
||||
"enabled_skills" => role.enabled_skills = parse_string_or_array(value),
|
||||
"enabled_skills" => role.enabled_skills = value.as_str().map(|v| v.to_string()),
|
||||
"auto_continue" => role.auto_continue = value.as_bool(),
|
||||
"max_auto_continues" => {
|
||||
role.max_auto_continues = value.as_u64().map(|v| v as usize)
|
||||
@@ -128,10 +112,6 @@ 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())
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
@@ -167,21 +147,17 @@ impl Role {
|
||||
if let Some(top_p) = self.top_p() {
|
||||
metadata.push(format!("top_p: {top_p}"));
|
||||
}
|
||||
if let Some(enabled_tools) = &self.enabled_tools {
|
||||
let inline = serde_json::to_string(enabled_tools).unwrap_or_else(|_| "[]".to_string());
|
||||
metadata.push(format!("enabled_tools: {inline}"));
|
||||
if let Some(enabled_tools) = self.enabled_tools() {
|
||||
metadata.push(format!("enabled_tools: {enabled_tools}"));
|
||||
}
|
||||
if let Some(enabled_mcp_servers) = &self.enabled_mcp_servers {
|
||||
let inline =
|
||||
serde_json::to_string(enabled_mcp_servers).unwrap_or_else(|_| "[]".to_string());
|
||||
metadata.push(format!("enabled_mcp_servers: {inline}"));
|
||||
if let Some(enabled_mcp_servers) = self.enabled_mcp_servers() {
|
||||
metadata.push(format!("enabled_mcp_servers: {enabled_mcp_servers}"));
|
||||
}
|
||||
if let Some(skills_enabled) = self.skills_enabled {
|
||||
metadata.push(format!("skills_enabled: {skills_enabled}"));
|
||||
}
|
||||
if let Some(enabled_skills) = &self.enabled_skills {
|
||||
let inline = serde_json::to_string(enabled_skills).unwrap_or_else(|_| "[]".to_string());
|
||||
metadata.push(format!("enabled_skills: {inline}"));
|
||||
metadata.push(format!("enabled_skills: {enabled_skills}"));
|
||||
}
|
||||
if let Some(auto_continue) = self.auto_continue {
|
||||
metadata.push(format!("auto_continue: {auto_continue}"));
|
||||
@@ -197,14 +173,6 @@ 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() {
|
||||
@@ -257,8 +225,8 @@ impl Role {
|
||||
model: &Model,
|
||||
temperature: Option<f64>,
|
||||
top_p: Option<f64>,
|
||||
enabled_tools: Option<Vec<String>>,
|
||||
enabled_mcp_servers: Option<Vec<String>>,
|
||||
enabled_tools: Option<String>,
|
||||
enabled_mcp_servers: Option<String>,
|
||||
) {
|
||||
self.set_model(model.clone());
|
||||
if temperature.is_some() {
|
||||
@@ -315,19 +283,11 @@ 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
|
||||
}
|
||||
|
||||
pub fn enabled_skills(&self) -> Option<&[String]> {
|
||||
pub fn enabled_skills(&self) -> Option<&str> {
|
||||
self.enabled_skills.as_deref()
|
||||
}
|
||||
|
||||
@@ -400,11 +360,11 @@ impl RoleLike for Role {
|
||||
self.top_p
|
||||
}
|
||||
|
||||
fn enabled_tools(&self) -> Option<Vec<String>> {
|
||||
fn enabled_tools(&self) -> Option<String> {
|
||||
self.enabled_tools.clone()
|
||||
}
|
||||
|
||||
fn enabled_mcp_servers(&self) -> Option<Vec<String>> {
|
||||
fn enabled_mcp_servers(&self) -> Option<String> {
|
||||
self.enabled_mcp_servers.clone()
|
||||
}
|
||||
|
||||
@@ -423,37 +383,15 @@ impl RoleLike for Role {
|
||||
self.top_p = value;
|
||||
}
|
||||
|
||||
fn set_enabled_tools(&mut self, value: Option<Vec<String>>) {
|
||||
fn set_enabled_tools(&mut self, value: Option<String>) {
|
||||
self.enabled_tools = value;
|
||||
}
|
||||
|
||||
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>) {
|
||||
fn set_enabled_mcp_servers(&mut self, value: Option<String>) {
|
||||
self.enabled_mcp_servers = value;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_string_or_array(value: &Value) -> Option<Vec<String>> {
|
||||
if value.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(s) = value.as_str() {
|
||||
return Some(csv_to_vec(s));
|
||||
}
|
||||
|
||||
if let Some(arr) = value.as_array() {
|
||||
let items: Vec<String> = arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
return Some(items);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) {
|
||||
let mut text = prompt;
|
||||
let mut search_input = true;
|
||||
@@ -528,20 +466,14 @@ mod tests {
|
||||
fn role_new_parses_enabled_tools() {
|
||||
let content = "---\nenabled_tools: tool1,tool2\n---\nPrompt";
|
||||
let role = Role::new("test", content);
|
||||
assert_eq!(
|
||||
role.enabled_tools(),
|
||||
Some(vec!["tool1".to_string(), "tool2".to_string()])
|
||||
);
|
||||
assert_eq!(role.enabled_tools(), Some("tool1,tool2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_new_parses_enabled_mcp_servers() {
|
||||
let content = "---\nenabled_mcp_servers: github,jira\n---\nPrompt";
|
||||
let role = Role::new("test", content);
|
||||
assert_eq!(
|
||||
role.enabled_mcp_servers(),
|
||||
Some(vec!["github".to_string(), "jira".to_string()])
|
||||
);
|
||||
assert_eq!(role.enabled_mcp_servers(), Some("github,jira".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+19
-93
@@ -24,26 +24,14 @@ pub struct Session {
|
||||
temperature: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
top_p: Option<f64>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "super::deserialize_csv_or_vec"
|
||||
)]
|
||||
enabled_tools: Option<Vec<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "super::deserialize_csv_or_vec"
|
||||
)]
|
||||
enabled_mcp_servers: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled_tools: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled_mcp_servers: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
skills_enabled: Option<bool>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "super::deserialize_csv_or_vec"
|
||||
)]
|
||||
enabled_skills: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled_skills: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
save_session: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -56,10 +44,6 @@ 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>,
|
||||
@@ -99,19 +83,12 @@ impl Session {
|
||||
self.skills_enabled
|
||||
}
|
||||
|
||||
pub fn enabled_skills(&self) -> Option<&[String]> {
|
||||
pub fn enabled_skills(&self) -> Option<&str> {
|
||||
self.enabled_skills.as_deref()
|
||||
}
|
||||
|
||||
pub fn set_skills_enabled(&mut self, value: Option<bool>) {
|
||||
if self.skills_enabled != value {
|
||||
self.skills_enabled = value;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_from_ctx(ctx: &RequestContext, app: &AppConfig, name: &str) -> Result<Self> {
|
||||
let role = ctx.extract_role(app)?;
|
||||
pub fn new_from_ctx(ctx: &RequestContext, app: &AppConfig, name: &str) -> Self {
|
||||
let role = ctx.extract_role(app);
|
||||
let mut session = Self {
|
||||
name: name.to_string(),
|
||||
save_session: app.save_session,
|
||||
@@ -119,7 +96,7 @@ impl Session {
|
||||
};
|
||||
session.set_role(role);
|
||||
session.dirty = false;
|
||||
Ok(session)
|
||||
session
|
||||
}
|
||||
|
||||
pub fn load_from_ctx(
|
||||
@@ -205,16 +182,10 @@ impl Session {
|
||||
data["top_p"] = top_p.into();
|
||||
}
|
||||
if let Some(enabled_tools) = self.enabled_tools() {
|
||||
data["enabled_tools"] = json!(enabled_tools);
|
||||
data["enabled_tools"] = enabled_tools.into();
|
||||
}
|
||||
if let Some(enabled_mcp_servers) = self.enabled_mcp_servers() {
|
||||
data["enabled_mcp_servers"] = json!(enabled_mcp_servers);
|
||||
}
|
||||
if let Some(skills_enabled) = self.skills_enabled() {
|
||||
data["skills_enabled"] = skills_enabled.into();
|
||||
}
|
||||
if let Some(enabled_skills) = self.enabled_skills() {
|
||||
data["enabled_skills"] = json!(enabled_skills);
|
||||
data["enabled_mcp_servers"] = enabled_mcp_servers.into();
|
||||
}
|
||||
if let Some(save_session) = self.save_session() {
|
||||
data["save_session"] = save_session.into();
|
||||
@@ -231,12 +202,6 @@ 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() {
|
||||
@@ -277,19 +242,11 @@ impl Session {
|
||||
}
|
||||
|
||||
if let Some(enabled_tools) = self.enabled_tools() {
|
||||
items.push(("enabled_tools", enabled_tools.join(",")));
|
||||
items.push(("enabled_tools", enabled_tools));
|
||||
}
|
||||
|
||||
if let Some(enabled_mcp_servers) = self.enabled_mcp_servers() {
|
||||
items.push(("enabled_mcp_servers", enabled_mcp_servers.join(",")));
|
||||
}
|
||||
|
||||
if let Some(skills_enabled) = self.skills_enabled() {
|
||||
items.push(("skills_enabled", skills_enabled.to_string()));
|
||||
}
|
||||
|
||||
if let Some(enabled_skills) = self.enabled_skills() {
|
||||
items.push(("enabled_skills", enabled_skills.join(",")));
|
||||
items.push(("enabled_mcp_servers", enabled_mcp_servers));
|
||||
}
|
||||
|
||||
if let Some(save_session) = self.save_session() {
|
||||
@@ -315,15 +272,6 @@ 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()));
|
||||
@@ -465,14 +413,6 @@ 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;
|
||||
@@ -487,20 +427,6 @@ 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;
|
||||
@@ -756,11 +682,11 @@ impl RoleLike for Session {
|
||||
self.top_p
|
||||
}
|
||||
|
||||
fn enabled_tools(&self) -> Option<Vec<String>> {
|
||||
fn enabled_tools(&self) -> Option<String> {
|
||||
self.enabled_tools.clone()
|
||||
}
|
||||
|
||||
fn enabled_mcp_servers(&self) -> Option<Vec<String>> {
|
||||
fn enabled_mcp_servers(&self) -> Option<String> {
|
||||
self.enabled_mcp_servers.clone()
|
||||
}
|
||||
|
||||
@@ -787,14 +713,14 @@ impl RoleLike for Session {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_enabled_tools(&mut self, value: Option<Vec<String>>) {
|
||||
fn set_enabled_tools(&mut self, value: Option<String>) {
|
||||
if self.enabled_tools != value {
|
||||
self.enabled_tools = value;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>) {
|
||||
fn set_enabled_mcp_servers(&mut self, value: Option<String>) {
|
||||
if self.enabled_mcp_servers != value {
|
||||
self.enabled_mcp_servers = value;
|
||||
self.dirty = true;
|
||||
@@ -858,7 +784,7 @@ mod tests {
|
||||
functions: Functions::default(),
|
||||
});
|
||||
let ctx = RequestContext::new(app_state, WorkingMode::Cmd);
|
||||
let session = Session::new_from_ctx(&ctx, &app_config, "test-session").unwrap();
|
||||
let session = Session::new_from_ctx(&ctx, &app_config, "test-session");
|
||||
|
||||
assert_eq!(session.name(), "test-session");
|
||||
assert_eq!(session.save_session(), app_config.save_session);
|
||||
|
||||
+9
-34
@@ -33,9 +33,9 @@ pub struct Skill {
|
||||
#[serde(default)]
|
||||
body: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled_tools: Option<Vec<String>>,
|
||||
enabled_tools: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled_mcp_servers: Option<Vec<String>>,
|
||||
enabled_mcp_servers: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
auto_unload: Option<bool>,
|
||||
}
|
||||
@@ -69,10 +69,10 @@ impl Skill {
|
||||
}
|
||||
}
|
||||
"enabled_tools" => {
|
||||
skill.enabled_tools = parse_skill_string_or_array(value);
|
||||
skill.enabled_tools = value.as_str().map(|v| v.to_string());
|
||||
}
|
||||
"enabled_mcp_servers" => {
|
||||
skill.enabled_mcp_servers = parse_skill_string_or_array(value);
|
||||
skill.enabled_mcp_servers = value.as_str().map(|v| v.to_string());
|
||||
}
|
||||
"auto_unload" => {
|
||||
skill.auto_unload = value.as_bool();
|
||||
@@ -116,7 +116,6 @@ impl Skill {
|
||||
}
|
||||
|
||||
pub fn load(name: &str) -> Result<Self> {
|
||||
paths::validate_skill_name(name)?;
|
||||
let path = paths::skill_file(name);
|
||||
let content = read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read skill '{name}' at {}", path.display()))?;
|
||||
@@ -135,11 +134,11 @@ impl Skill {
|
||||
&self.body
|
||||
}
|
||||
|
||||
pub fn enabled_tools(&self) -> Option<&[String]> {
|
||||
pub fn enabled_tools(&self) -> Option<&str> {
|
||||
self.enabled_tools.as_deref()
|
||||
}
|
||||
|
||||
pub fn enabled_mcp_servers(&self) -> Option<&[String]> {
|
||||
pub fn enabled_mcp_servers(&self) -> Option<&str> {
|
||||
self.enabled_mcp_servers.as_deref()
|
||||
}
|
||||
|
||||
@@ -158,29 +157,11 @@ impl Skill {
|
||||
fn declares_mcp_servers(&self) -> bool {
|
||||
self.enabled_mcp_servers
|
||||
.as_deref()
|
||||
.map(|servers| !servers.is_empty())
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_skill_string_or_array(value: &Value) -> Option<Vec<String>> {
|
||||
if value.is_null() {
|
||||
return None;
|
||||
}
|
||||
if let Some(s) = value.as_str() {
|
||||
return Some(csv_to_vec(s));
|
||||
}
|
||||
if let Some(arr) = value.as_array() {
|
||||
let items: Vec<String> = arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
return Some(items);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -208,14 +189,8 @@ mod tests {
|
||||
|
||||
assert_eq!(skill.name(), "git-master");
|
||||
assert_eq!(skill.description(), "Atomic commits, rebase surgery");
|
||||
assert_eq!(
|
||||
skill.enabled_tools(),
|
||||
Some(["shell".to_string(), "fs".to_string()].as_slice())
|
||||
);
|
||||
assert_eq!(
|
||||
skill.enabled_mcp_servers(),
|
||||
Some(["github".to_string()].as_slice())
|
||||
);
|
||||
assert_eq!(skill.enabled_tools(), Some("shell,fs"));
|
||||
assert_eq!(skill.enabled_mcp_servers(), Some("github"));
|
||||
assert!(skill.auto_unload());
|
||||
assert_eq!(skill.body(), "You are a git expert");
|
||||
}
|
||||
|
||||
+43
-268
@@ -3,16 +3,14 @@ use super::app_config::AppConfig;
|
||||
use super::paths;
|
||||
use super::role::Role;
|
||||
use super::session::Session;
|
||||
use super::skill::Skill;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
use anyhow::{Result, bail};
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SkillPolicy {
|
||||
pub skills_enabled: bool,
|
||||
pub enabled: HashSet<String>,
|
||||
pub compatible_enabled: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl SkillPolicy {
|
||||
@@ -29,27 +27,20 @@ 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, H>(
|
||||
fn effective_with<F, G>(
|
||||
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,35 +67,27 @@ impl SkillPolicy {
|
||||
.map(|v| v.iter().cloned().collect());
|
||||
|
||||
let enabled_raw: Option<Vec<String>> = session
|
||||
.and_then(|s| s.enabled_skills().map(|v| v.to_vec()))
|
||||
.and_then(|s| parse_csv_opt(s.enabled_skills()))
|
||||
.or_else(|| agent.and_then(|a| a.enabled_skills().map(|v| v.to_vec())))
|
||||
.or_else(|| role.and_then(|r| r.enabled_skills().map(|v| v.to_vec())))
|
||||
.or_else(|| global.enabled_skills.clone());
|
||||
.or_else(|| role.and_then(|r| parse_csv_opt(r.enabled_skills())))
|
||||
.or_else(|| parse_csv_opt(global.enabled_skills.as_deref()));
|
||||
|
||||
let enabled: HashSet<String> = match enabled_raw {
|
||||
Some(explicit) => {
|
||||
let set: HashSet<String> = explicit.into_iter().collect();
|
||||
for name in &set {
|
||||
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");
|
||||
}
|
||||
|
||||
if let Some(vs) = &visible
|
||||
&& !vs.contains(name)
|
||||
{
|
||||
bail!(
|
||||
"enabled_skills references skill '{name}' which is not installed"
|
||||
"enabled_skills references skill '{name}' which is not in visible_skills"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
None => match &visible {
|
||||
@@ -113,21 +96,9 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -136,9 +107,17 @@ impl SkillPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_csv_opt(s: Option<&str>) -> Option<Vec<String>> {
|
||||
s.map(|raw| {
|
||||
raw.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::csv_to_vec;
|
||||
use super::*;
|
||||
|
||||
fn always_true(_: &str) -> bool {
|
||||
@@ -149,10 +128,6 @@ mod tests {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn all_compatible(_: &str, _: bool) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn make_app_config(
|
||||
skills_enabled: bool,
|
||||
enabled: Option<&str>,
|
||||
@@ -160,7 +135,7 @@ mod tests {
|
||||
) -> AppConfig {
|
||||
AppConfig {
|
||||
skills_enabled,
|
||||
enabled_skills: enabled.map(csv_to_vec),
|
||||
enabled_skills: enabled.map(|s| s.to_string()),
|
||||
visible_skills: visible.map(|v| v.iter().map(|s| s.to_string()).collect()),
|
||||
..AppConfig::default()
|
||||
}
|
||||
@@ -170,15 +145,8 @@ 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,
|
||||
&all_compatible,
|
||||
)
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.skills_enabled);
|
||||
@@ -190,15 +158,8 @@ 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,
|
||||
&all_compatible,
|
||||
)
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &installed)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(policy.enabled.len(), 2);
|
||||
@@ -210,15 +171,8 @@ 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,
|
||||
&all_compatible,
|
||||
)
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(policy.enabled.len(), 2);
|
||||
@@ -230,15 +184,8 @@ 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,
|
||||
&all_compatible,
|
||||
)
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.enabled.contains("alpha"));
|
||||
@@ -258,7 +205,6 @@ mod tests {
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -278,7 +224,6 @@ mod tests {
|
||||
None,
|
||||
&always_true,
|
||||
&empty_installed,
|
||||
&all_compatible,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -292,15 +237,9 @@ mod tests {
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
let policy = SkillPolicy::effective_with(
|
||||
&global,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&always_true,
|
||||
&|| vec!["alpha".to_string()],
|
||||
&all_compatible,
|
||||
)
|
||||
let policy = SkillPolicy::effective_with(&global, None, None, None, &always_true, &|| {
|
||||
vec!["alpha".to_string()]
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(!policy.allows("alpha"));
|
||||
@@ -310,15 +249,8 @@ 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,
|
||||
&all_compatible,
|
||||
)
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.allows("alpha"));
|
||||
@@ -329,15 +261,8 @@ 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,
|
||||
&all_compatible,
|
||||
)
|
||||
let err =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &|_| false, &empty_installed)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("not installed"));
|
||||
@@ -348,21 +273,11 @@ 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,
|
||||
&all_compatible,
|
||||
)
|
||||
let err =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("not in the global 'visible_skills'")
|
||||
);
|
||||
assert!(err.to_string().contains("not in visible_skills"));
|
||||
assert!(err.to_string().contains("beta"));
|
||||
}
|
||||
|
||||
@@ -370,15 +285,8 @@ 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,
|
||||
&all_compatible,
|
||||
)
|
||||
let policy =
|
||||
SkillPolicy::effective_with(&global, None, None, None, &|_| false, &empty_installed)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.enabled.is_empty());
|
||||
@@ -396,142 +304,9 @@ 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use super::role::{Role, RoleLike};
|
||||
use super::skill::Skill;
|
||||
use super::skill_policy::SkillPolicy;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use anyhow::{Result, bail};
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
@@ -24,10 +23,12 @@ impl SkillRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unload(&mut self, name: &str) -> Result<Skill> {
|
||||
self.loaded
|
||||
.shift_remove(name)
|
||||
.ok_or_else(|| anyhow!("Skill '{name}' is not loaded"))
|
||||
pub fn unload(&mut self, name: &str) -> Result<()> {
|
||||
if self.loaded.shift_remove(name).is_none() {
|
||||
bail!("Skill '{name}' is not loaded");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn loaded_names(&self) -> Vec<String> {
|
||||
@@ -37,8 +38,8 @@ impl SkillRegistry {
|
||||
pub fn loaded_mcp_servers(&self) -> BTreeSet<String> {
|
||||
let mut out = BTreeSet::new();
|
||||
for skill in self.loaded.values() {
|
||||
if let Some(servers) = skill.enabled_mcp_servers() {
|
||||
for token in servers {
|
||||
if let Some(csv) = skill.enabled_mcp_servers() {
|
||||
for token in csv.split(',') {
|
||||
let t = token.trim();
|
||||
if !t.is_empty() {
|
||||
out.insert(t.to_string());
|
||||
@@ -57,32 +58,23 @@ impl SkillRegistry {
|
||||
self.loaded.retain(|_, skill| !skill.auto_unload());
|
||||
}
|
||||
|
||||
pub fn effective_role(&self, base: &Role, policy: &SkillPolicy) -> Role {
|
||||
if !policy.skills_enabled || self.loaded.is_empty() {
|
||||
pub fn effective_role(&self, base: &Role) -> Role {
|
||||
if self.loaded.is_empty() {
|
||||
return base.clone();
|
||||
}
|
||||
|
||||
let mut effective = base.clone();
|
||||
let skip_body = effective.is_embedded_prompt();
|
||||
|
||||
let base_tools = effective.enabled_tools();
|
||||
let base_tools_set = base_tools.is_some();
|
||||
let base_mcps = effective.enabled_mcp_servers();
|
||||
let base_mcps_set = base_mcps.is_some();
|
||||
let base_tools_set = effective.enabled_tools().is_some();
|
||||
let base_mcps_set = effective.enabled_mcp_servers().is_some();
|
||||
|
||||
let mut tools: BTreeSet<String> = base_tools.unwrap_or_default().into_iter().collect();
|
||||
let mut mcps: BTreeSet<String> = base_mcps.unwrap_or_default().into_iter().collect();
|
||||
let mut tools = parse_csv(effective.enabled_tools().as_deref());
|
||||
let mut mcps = parse_csv(effective.enabled_mcp_servers().as_deref());
|
||||
|
||||
for (name, skill) in &self.loaded {
|
||||
if !policy.allows(name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(skill_tools) = skill.enabled_tools() {
|
||||
tools.extend(skill_tools.iter().cloned());
|
||||
}
|
||||
if let Some(servers) = skill.enabled_mcp_servers() {
|
||||
mcps.extend(servers.iter().cloned());
|
||||
}
|
||||
for (_, skill) in &self.loaded {
|
||||
tools.extend(parse_csv(skill.enabled_tools()));
|
||||
mcps.extend(parse_csv(skill.enabled_mcp_servers()));
|
||||
if !skip_body && !skill.body().is_empty() {
|
||||
let separator = if effective.is_empty_prompt() {
|
||||
""
|
||||
@@ -95,31 +87,39 @@ impl SkillRegistry {
|
||||
}
|
||||
|
||||
if base_tools_set || !tools.is_empty() {
|
||||
effective.set_enabled_tools(Some(tools.into_iter().collect()));
|
||||
effective.set_enabled_tools(Some(join_csv(&tools)));
|
||||
}
|
||||
|
||||
if base_mcps_set || !mcps.is_empty() {
|
||||
effective.set_enabled_mcp_servers(Some(mcps.into_iter().collect()));
|
||||
effective.set_enabled_mcp_servers(Some(join_csv(&mcps)));
|
||||
}
|
||||
|
||||
effective
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_csv(s: Option<&str>) -> BTreeSet<String> {
|
||||
let mut set = BTreeSet::new();
|
||||
if let Some(raw) = s {
|
||||
for token in raw.split(',') {
|
||||
let trimmed = token.trim();
|
||||
if !trimmed.is_empty() {
|
||||
set.insert(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
fn join_csv(set: &BTreeSet<String>) -> String {
|
||||
set.iter().cloned().collect::<Vec<_>>().join(",")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl SkillRegistry {
|
||||
fn insert_for_test(&mut self, skill: Skill) {
|
||||
self.loaded.insert(skill.name().to_string(), skill);
|
||||
}
|
||||
|
||||
fn effective_role_for_test(&self, base: &Role) -> Role {
|
||||
let policy = SkillPolicy {
|
||||
skills_enabled: true,
|
||||
enabled: self.loaded.keys().cloned().collect(),
|
||||
compatible_enabled: self.loaded.keys().cloned().collect(),
|
||||
};
|
||||
self.effective_role(base, &policy)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -140,7 +140,7 @@ mod tests {
|
||||
let base = Role::new("test", "You are a helper");
|
||||
let registry = SkillRegistry::default();
|
||||
|
||||
let effective = registry.effective_role_for_test(&base);
|
||||
let effective = registry.effective_role(&base);
|
||||
|
||||
assert_eq!(effective.prompt(), base.prompt());
|
||||
}
|
||||
@@ -151,7 +151,7 @@ mod tests {
|
||||
registry.insert_for_test(make_skill("git-master", "description: D", "Git knowledge"));
|
||||
|
||||
let base = Role::new("test", "You are a helper");
|
||||
let effective = registry.effective_role_for_test(&base);
|
||||
let effective = registry.effective_role(&base);
|
||||
|
||||
assert_eq!(effective.prompt(), "You are a helper\n\nGit knowledge");
|
||||
}
|
||||
@@ -163,7 +163,7 @@ mod tests {
|
||||
registry.insert_for_test(make_skill("b", "", "Beta body"));
|
||||
|
||||
let base = Role::new("test", "Base");
|
||||
let effective = registry.effective_role_for_test(&base);
|
||||
let effective = registry.effective_role(&base);
|
||||
|
||||
assert_eq!(effective.prompt(), "Base\n\nAlpha body\n\nBeta body");
|
||||
}
|
||||
@@ -175,7 +175,7 @@ mod tests {
|
||||
registry.insert_for_test(make_skill("b", "", "Beta"));
|
||||
|
||||
let base = Role::new("test", "");
|
||||
let effective = registry.effective_role_for_test(&base);
|
||||
let effective = registry.effective_role(&base);
|
||||
|
||||
assert_eq!(effective.prompt(), "Alpha\n\nBeta");
|
||||
}
|
||||
@@ -190,11 +190,11 @@ mod tests {
|
||||
));
|
||||
|
||||
let base = Role::new("test", "Process: __INPUT__");
|
||||
let effective = registry.effective_role_for_test(&base);
|
||||
let effective = registry.effective_role(&base);
|
||||
|
||||
assert_eq!(effective.prompt(), "Process: __INPUT__");
|
||||
let tools = effective.enabled_tools().expect("tools set by skill");
|
||||
assert!(tools.iter().any(|s| s == "shell"));
|
||||
assert!(tools.contains("shell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -203,7 +203,7 @@ mod tests {
|
||||
registry.insert_for_test(make_skill("knowledge", "enabled_tools: fs", ""));
|
||||
|
||||
let base = Role::new("test", "Base");
|
||||
let effective = registry.effective_role_for_test(&base);
|
||||
let effective = registry.effective_role(&base);
|
||||
|
||||
assert_eq!(effective.prompt(), "Base");
|
||||
}
|
||||
@@ -223,16 +223,16 @@ mod tests {
|
||||
));
|
||||
|
||||
let mut base = Role::new("test", "body");
|
||||
base.set_enabled_tools(Some(vec!["web_search".to_string()]));
|
||||
base.set_enabled_tools(Some("web_search".to_string()));
|
||||
|
||||
let effective = registry.effective_role_for_test(&base);
|
||||
let effective = registry.effective_role(&base);
|
||||
|
||||
let tools_vec = effective.enabled_tools().unwrap();
|
||||
let tools: BTreeSet<&str> = tools_vec.iter().map(|s| s.as_str()).collect();
|
||||
let tools_str = effective.enabled_tools().unwrap();
|
||||
let tools: BTreeSet<&str> = tools_str.split(',').collect();
|
||||
assert_eq!(tools, BTreeSet::from(["fs", "git", "shell", "web_search"]));
|
||||
|
||||
let mcps_vec = effective.enabled_mcp_servers().unwrap();
|
||||
let mcps: BTreeSet<&str> = mcps_vec.iter().map(|s| s.as_str()).collect();
|
||||
let mcps_str = effective.enabled_mcp_servers().unwrap();
|
||||
let mcps: BTreeSet<&str> = mcps_str.split(',').collect();
|
||||
assert_eq!(mcps, BTreeSet::from(["github", "jira"]));
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ mod tests {
|
||||
registry.insert_for_test(make_skill("knowledge", "", "Pure knowledge"));
|
||||
|
||||
let base = Role::new("test", "Base");
|
||||
let effective = registry.effective_role_for_test(&base);
|
||||
let effective = registry.effective_role(&base);
|
||||
|
||||
assert!(effective.enabled_tools().is_none());
|
||||
assert!(effective.enabled_mcp_servers().is_none());
|
||||
@@ -254,10 +254,10 @@ mod tests {
|
||||
registry.insert_for_test(make_skill("knowledge", "", "Pure knowledge"));
|
||||
|
||||
let mut base = Role::new("test", "Base");
|
||||
base.set_enabled_tools(Some(Vec::new()));
|
||||
let effective = registry.effective_role_for_test(&base);
|
||||
base.set_enabled_tools(Some(String::new()));
|
||||
let effective = registry.effective_role(&base);
|
||||
|
||||
assert_eq!(effective.enabled_tools().as_deref(), Some([].as_slice()));
|
||||
assert_eq!(effective.enabled_tools().as_deref(), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+46
-38
@@ -14,11 +14,9 @@ pub fn skill_function_declarations() -> Vec<FunctionDeclaration> {
|
||||
FunctionDeclaration {
|
||||
name: format!("{SKILL_FUNCTION_PREFIX}list"),
|
||||
description:
|
||||
"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."
|
||||
"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."
|
||||
.to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
@@ -30,10 +28,9 @@ pub fn skill_function_declarations() -> Vec<FunctionDeclaration> {
|
||||
FunctionDeclaration {
|
||||
name: format!("{SKILL_FUNCTION_PREFIX}load"),
|
||||
description:
|
||||
"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."
|
||||
"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."
|
||||
.to_string(),
|
||||
parameters: JsonSchema {
|
||||
type_value: Some("object".to_string()),
|
||||
@@ -105,14 +102,11 @@ pub async fn handle_skill_tool(
|
||||
}
|
||||
|
||||
fn handle_list(ctx: &RequestContext, policy: &SkillPolicy) -> Result<Value> {
|
||||
let visible_names: Vec<String> = match ctx.app.config.visible_skills.as_deref() {
|
||||
Some(list) => list.to_vec(),
|
||||
None => paths::list_skills(),
|
||||
};
|
||||
let mcp_on = ctx.app.config.mcp_server_support;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for name in visible_names {
|
||||
if !policy.compatible_enabled.contains(&name) {
|
||||
for name in paths::list_skills() {
|
||||
if !policy.allows(&name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -123,12 +117,18 @@ 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(),
|
||||
"description": skill.description(),
|
||||
"grants_tools": skill.enabled_tools().unwrap_or_default(),
|
||||
"grants_mcp_servers": skill.enabled_mcp_servers().unwrap_or_default(),
|
||||
"grants_tools": csv_to_vec(skill.enabled_tools()),
|
||||
"grants_mcp_servers": csv_to_vec(skill.enabled_mcp_servers()),
|
||||
"loaded": ctx.skill_registry.is_loaded(skill.name()),
|
||||
}));
|
||||
}
|
||||
@@ -166,11 +166,11 @@ async fn handle_load(
|
||||
|
||||
let tools_declared = skill
|
||||
.enabled_tools()
|
||||
.map(|v| !v.is_empty())
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
let mcps_declared = skill
|
||||
.enabled_mcp_servers()
|
||||
.map(|v| !v.is_empty())
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if tools_declared && !function_calling_on {
|
||||
@@ -193,10 +193,7 @@ async fn handle_load(
|
||||
}
|
||||
|
||||
if let Err(e) = ctx.refresh_tool_scope(create_abort_signal()).await {
|
||||
if let Err(unload_err) = ctx.skill_registry.unload(name) {
|
||||
warn!("Failed to unload skill '{name}' during error recovery: {unload_err}");
|
||||
}
|
||||
|
||||
let _ = ctx.skill_registry.unload(name);
|
||||
return Ok(json!({
|
||||
"error": format!("Loaded skill '{name}' but failed to refresh tool scope: {e}")
|
||||
}));
|
||||
@@ -215,25 +212,12 @@ 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) {
|
||||
if let Err(e) = ctx.skill_registry.unload(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 {
|
||||
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}"
|
||||
)
|
||||
}));
|
||||
warn!("Unloaded skill '{name}' but failed to refresh tool scope: {e}");
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
@@ -242,6 +226,16 @@ async fn handle_unload(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
||||
}))
|
||||
}
|
||||
|
||||
fn csv_to_vec(csv: Option<&str>) -> Vec<String> {
|
||||
csv.map(|raw| {
|
||||
raw.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -299,4 +293,18 @@ mod tests {
|
||||
|
||||
assert!(required, "skill__list should have no required parameters");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_to_vec_empty_input() {
|
||||
assert!(csv_to_vec(None).is_empty());
|
||||
assert!(csv_to_vec(Some("")).is_empty());
|
||||
assert!(csv_to_vec(Some(" ")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_to_vec_parses_and_trims() {
|
||||
let v = csv_to_vec(Some("a, b ,c,, d"));
|
||||
|
||||
assert_eq!(v, vec!["a", "b", "c", "d"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,7 +469,7 @@ pub async fn run_agent_for_graph(
|
||||
child_ctx.init_agent_shared_variables()?;
|
||||
}
|
||||
|
||||
let input = Input::from_str(&child_ctx, prompt, None)?;
|
||||
let input = Input::from_str(&child_ctx, prompt, None);
|
||||
|
||||
debug!("Spawning agent '{agent_name}' for graph node as '{agent_id}'");
|
||||
|
||||
@@ -635,7 +635,7 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
|
||||
child_ctx.init_agent_shared_variables()?;
|
||||
}
|
||||
|
||||
let input = Input::from_str(&child_ctx, &prompt, None)?;
|
||||
let input = Input::from_str(&child_ctx, &prompt, None);
|
||||
|
||||
debug!("Spawning child agent '{agent_name}' as '{agent_id}'");
|
||||
|
||||
@@ -1228,7 +1228,7 @@ async fn summarize_output(ctx: &RequestContext, agent_name: &str, output: &str)
|
||||
"Summarize the following sub-agent output from '{}':\n\n{}",
|
||||
agent_name, output
|
||||
);
|
||||
let input = Input::from_str(ctx, &user_message, Some(role))?;
|
||||
let input = Input::from_str(ctx, &user_message, Some(role));
|
||||
|
||||
let summary = input.fetch_chat_text().await?;
|
||||
|
||||
|
||||
+10
-64
@@ -2,11 +2,7 @@ use super::state::StateManager;
|
||||
use super::structured;
|
||||
use super::types::LlmNode;
|
||||
use crate::client::{Model, ModelType, call_chat_completions};
|
||||
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::config::{Input, RequestContext, Role, RoleLike};
|
||||
use crate::utils::create_abort_signal;
|
||||
use anyhow::{Context, Error, Result, anyhow, bail};
|
||||
use serde_json::Value;
|
||||
@@ -109,7 +105,7 @@ async fn run(
|
||||
let (regular_tools, mcp_servers) = categorize_tools(node.tools.as_deref());
|
||||
validate_tools_subset(®ular_tools, &mcp_servers, parent_ctx)?;
|
||||
|
||||
let mut role = build_inline_role(
|
||||
let role = build_inline_role(
|
||||
node,
|
||||
instructions.as_deref(),
|
||||
®ular_tools,
|
||||
@@ -119,55 +115,7 @@ async fn run(
|
||||
|
||||
let saved_agent_skill_state = swap_in_node_skill_policy(node, parent_ctx);
|
||||
|
||||
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 {
|
||||
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) {
|
||||
tools.push(decl.name);
|
||||
}
|
||||
}
|
||||
role.set_enabled_tools(Some(tools));
|
||||
}
|
||||
|
||||
if should_inject_skill_instructions(&parent_ctx.app.config, &policy) {
|
||||
let app = &parent_ctx.app.config;
|
||||
let agent = parent_ctx.agent.as_ref();
|
||||
let inject = node
|
||||
.inject_skill_instructions
|
||||
.or_else(|| agent.map(|a| a.inject_skill_instructions()))
|
||||
.unwrap_or(app.inject_skill_instructions);
|
||||
|
||||
if inject {
|
||||
let instructions = node
|
||||
.skill_instructions
|
||||
.clone()
|
||||
.or_else(|| agent.and_then(|a| a.skill_instructions_value()))
|
||||
.or_else(|| app.skill_instructions.clone());
|
||||
let separator = if role.is_empty_prompt() { "" } else { "\n\n" };
|
||||
|
||||
role.append_to_prompt(separator);
|
||||
role.append_to_prompt(
|
||||
instructions
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_SKILL_INSTRUCTIONS),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let composed_role = parent_ctx.skill_registry.effective_role(&role, &policy);
|
||||
let composed_role = parent_ctx.skill_registry.effective_role(&role);
|
||||
|
||||
let saved_role = parent_ctx.role.clone();
|
||||
parent_ctx.role = Some(composed_role);
|
||||
@@ -247,7 +195,7 @@ async fn run_chat_loop(node: &LlmNode, prompt: &str, ctx: &mut RequestContext) -
|
||||
let abort = create_abort_signal();
|
||||
let app_cfg = Arc::clone(&ctx.app.config);
|
||||
let role_for_input = ctx.role.clone();
|
||||
let mut input = Input::from_str(ctx, prompt, role_for_input)?;
|
||||
let mut input = Input::from_str(ctx, prompt, role_for_input);
|
||||
let mut accumulated = String::new();
|
||||
|
||||
for turn in 0..node.max_iterations {
|
||||
@@ -308,18 +256,18 @@ fn build_inline_role(
|
||||
}
|
||||
|
||||
if node.tools.as_deref().unwrap_or_default().is_empty() {
|
||||
role.set_enabled_tools(Some(Vec::new()));
|
||||
role.set_enabled_mcp_servers(Some(Vec::new()));
|
||||
role.set_enabled_tools(Some(String::new()));
|
||||
role.set_enabled_mcp_servers(Some(String::new()));
|
||||
} else {
|
||||
if !regular_tools.is_empty() {
|
||||
role.set_enabled_tools(Some(regular_tools.to_vec()));
|
||||
role.set_enabled_tools(Some(regular_tools.join(",")));
|
||||
} else {
|
||||
role.set_enabled_tools(Some(Vec::new()));
|
||||
role.set_enabled_tools(Some(String::new()));
|
||||
}
|
||||
if !mcp_servers.is_empty() {
|
||||
role.set_enabled_mcp_servers(Some(mcp_servers.to_vec()));
|
||||
role.set_enabled_mcp_servers(Some(mcp_servers.join(",")));
|
||||
} else {
|
||||
role.set_enabled_mcp_servers(Some(Vec::new()));
|
||||
role.set_enabled_mcp_servers(Some(String::new()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,8 +432,6 @@ mod tests {
|
||||
timeout: None,
|
||||
skills_enabled: None,
|
||||
enabled_skills: None,
|
||||
inject_skill_instructions: None,
|
||||
skill_instructions: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,8 +55,8 @@ async fn extract_via_extractor(
|
||||
|
||||
fn build_extractor_role() -> Result<Role> {
|
||||
let mut role = Role::new(EXTRACTOR_ROLE_NAME, EXTRACTOR_ROLE_PROMPT);
|
||||
role.set_enabled_tools(Some(Vec::new()));
|
||||
role.set_enabled_mcp_servers(Some(Vec::new()));
|
||||
role.set_enabled_tools(Some(String::new()));
|
||||
role.set_enabled_mcp_servers(Some(String::new()));
|
||||
Ok(role)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ async fn run_one_shot(prompt: &str, ctx: &mut RequestContext) -> Result<String>
|
||||
let abort = create_abort_signal();
|
||||
let app_cfg = Arc::clone(&ctx.app.config);
|
||||
let role_for_input = ctx.role.clone();
|
||||
let input = Input::from_str(ctx, prompt, role_for_input)?;
|
||||
let input = Input::from_str(ctx, prompt, role_for_input);
|
||||
let client = input.create_client()?;
|
||||
ctx.before_chat_completion(&input)?;
|
||||
let (output, tool_results) =
|
||||
@@ -183,7 +183,7 @@ mod tests {
|
||||
fn build_extractor_role_disables_tools_and_mcp() {
|
||||
let role = build_extractor_role().expect("builtin role must exist");
|
||||
|
||||
assert_eq!(role.enabled_tools().as_deref(), Some([].as_slice()));
|
||||
assert_eq!(role.enabled_mcp_servers().as_deref(), Some([].as_slice()));
|
||||
assert_eq!(role.enabled_tools().as_deref(), Some(""));
|
||||
assert_eq!(role.enabled_mcp_servers().as_deref(), Some(""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,12 +37,6 @@ 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>,
|
||||
|
||||
@@ -311,12 +305,6 @@ 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 {
|
||||
|
||||
+9
-92
@@ -93,7 +93,6 @@ impl AgentValidationContext {
|
||||
pub struct GraphValidator {
|
||||
base_dir: PathBuf,
|
||||
agent_ctx: Option<AgentValidationContext>,
|
||||
skill_exists: fn(&str) -> bool,
|
||||
}
|
||||
|
||||
impl GraphValidator {
|
||||
@@ -101,7 +100,6 @@ impl GraphValidator {
|
||||
Self {
|
||||
base_dir: base_dir.into(),
|
||||
agent_ctx: None,
|
||||
skill_exists: paths::has_skill,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,12 +108,6 @@ 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);
|
||||
@@ -199,49 +191,6 @@ 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;
|
||||
@@ -258,22 +207,6 @@ 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)
|
||||
{
|
||||
@@ -950,8 +883,6 @@ 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(),
|
||||
@@ -1053,8 +984,6 @@ mod tests {
|
||||
timeout: None,
|
||||
skills_enabled: None,
|
||||
enabled_skills: None,
|
||||
inject_skill_instructions: None,
|
||||
skill_instructions: None,
|
||||
}),
|
||||
next: next.map(NextTargets::from),
|
||||
}
|
||||
@@ -1079,10 +1008,7 @@ mod tests {
|
||||
#[test]
|
||||
fn llm_node_skill_in_graph_set_passes() {
|
||||
let mut graph = graph_with(
|
||||
vec![
|
||||
("l", llm_node("l", None, Some("end"))),
|
||||
("end", end_node("end")),
|
||||
],
|
||||
vec![("l", llm_node("l", None, Some("end"))), ("end", end_node("end"))],
|
||||
"l",
|
||||
);
|
||||
graph.enabled_skills = Some(vec!["code-review".into(), "git-master".into()]);
|
||||
@@ -1105,10 +1031,7 @@ mod tests {
|
||||
#[test]
|
||||
fn llm_node_skill_not_in_graph_set_errors() {
|
||||
let mut graph = graph_with(
|
||||
vec![
|
||||
("l", llm_node("l", None, Some("end"))),
|
||||
("end", end_node("end")),
|
||||
],
|
||||
vec![("l", llm_node("l", None, Some("end"))), ("end", end_node("end"))],
|
||||
"l",
|
||||
);
|
||||
graph.enabled_skills = Some(vec!["code-review".into()]);
|
||||
@@ -1120,10 +1043,10 @@ mod tests {
|
||||
|
||||
assert!(!result.is_valid());
|
||||
assert!(
|
||||
result
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message.contains("'git-master'") && e.message.contains("graph-level")),
|
||||
result.errors.iter().any(|e| e
|
||||
.message
|
||||
.contains("'git-master'")
|
||||
&& e.message.contains("graph-level")),
|
||||
"expected git-master subset error, got: {:?}",
|
||||
result.errors
|
||||
);
|
||||
@@ -1132,10 +1055,7 @@ mod tests {
|
||||
#[test]
|
||||
fn llm_node_empty_skill_name_errors() {
|
||||
let mut graph = graph_with(
|
||||
vec![
|
||||
("l", llm_node("l", None, Some("end"))),
|
||||
("end", end_node("end")),
|
||||
],
|
||||
vec![("l", llm_node("l", None, Some("end"))), ("end", end_node("end"))],
|
||||
"l",
|
||||
);
|
||||
graph.enabled_skills = Some(vec!["code-review".into()]);
|
||||
@@ -1159,10 +1079,7 @@ mod tests {
|
||||
#[test]
|
||||
fn llm_node_skill_when_no_graph_set_is_permitted_by_validator() {
|
||||
let mut graph = graph_with(
|
||||
vec![
|
||||
("l", llm_node("l", None, Some("end"))),
|
||||
("end", end_node("end")),
|
||||
],
|
||||
vec![("l", llm_node("l", None, Some("end"))), ("end", end_node("end"))],
|
||||
"l",
|
||||
);
|
||||
if let NodeType::Llm(ref mut n) = graph.nodes.get_mut("l").unwrap().node_type {
|
||||
@@ -1396,7 +1313,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn validator() -> GraphValidator {
|
||||
GraphValidator::new(env::current_dir().unwrap()).with_skill_exists(|_: &str| true)
|
||||
GraphValidator::new(env::current_dir().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+8
-12
@@ -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,18 +197,14 @@ async fn run(
|
||||
println!("{skills}");
|
||||
return Ok(());
|
||||
}
|
||||
let skills = cli.skills();
|
||||
if skills.len() == 1 {
|
||||
let name = &skills[0];
|
||||
paths::validate_skill_name(name)?;
|
||||
if !paths::has_skill(name) {
|
||||
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(());
|
||||
}
|
||||
} else if skills.len() > 1 {
|
||||
for name in &skills {
|
||||
paths::validate_skill_name(name)?;
|
||||
if cli.skill.len() > 1 {
|
||||
for name in &cli.skill {
|
||||
if !paths::has_skill(name) {
|
||||
bail!("Skill '{name}' is not installed");
|
||||
}
|
||||
@@ -327,7 +323,7 @@ async fn run(
|
||||
.await?;
|
||||
}
|
||||
|
||||
for name in &cli.skills() {
|
||||
for name in &cli.skill {
|
||||
ctx.load_skill_repl(name, abort_signal.clone()).await?;
|
||||
}
|
||||
|
||||
@@ -461,7 +457,7 @@ async fn shell_execute(
|
||||
}
|
||||
'd' => {
|
||||
let role = ctx.retrieve_role(app.as_ref(), EXPLAIN_SHELL_ROLE)?;
|
||||
let input = Input::from_str(ctx, &eval_str, Some(role))?;
|
||||
let input = Input::from_str(ctx, &eval_str, Some(role));
|
||||
if input.stream() {
|
||||
call_chat_completions_streaming(
|
||||
&input,
|
||||
@@ -506,7 +502,7 @@ async fn create_input(
|
||||
) -> Result<Input> {
|
||||
let text = text.unwrap_or_default();
|
||||
let input = if file.is_empty() {
|
||||
Input::from_str(ctx, &text, None)?
|
||||
Input::from_str(ctx, &text, None)
|
||||
} else {
|
||||
Input::from_files_with_spinner(ctx, &text, file.to_vec(), None, abort_signal).await?
|
||||
};
|
||||
|
||||
+12
-17
@@ -146,7 +146,7 @@ impl McpRegistry {
|
||||
pub async fn init(
|
||||
log_path: Option<PathBuf>,
|
||||
start_mcp_servers: bool,
|
||||
enabled_mcp_servers: Option<Vec<String>>,
|
||||
enabled_mcp_servers: Option<String>,
|
||||
abort_signal: AbortSignal,
|
||||
app_config: &AppConfig,
|
||||
vault: &Vault,
|
||||
@@ -216,7 +216,7 @@ impl McpRegistry {
|
||||
|
||||
async fn start_select_mcp_servers(
|
||||
&mut self,
|
||||
enabled_mcp_servers: Option<Vec<String>>,
|
||||
enabled_mcp_servers: Option<String>,
|
||||
) -> Result<()> {
|
||||
if self.config.is_none() {
|
||||
debug!(
|
||||
@@ -292,15 +292,15 @@ impl McpRegistry {
|
||||
Ok((id.to_string(), service, catalog))
|
||||
}
|
||||
|
||||
fn resolve_server_ids(&self, enabled_mcp_servers: Option<Vec<String>>) -> Vec<String> {
|
||||
fn resolve_server_ids(&self, enabled_mcp_servers: Option<String>) -> Vec<String> {
|
||||
if let Some(config) = &self.config
|
||||
&& let Some(servers) = enabled_mcp_servers
|
||||
{
|
||||
if servers.iter().any(|s| s.trim() == "all") {
|
||||
if servers == "all" {
|
||||
config.mcp_servers.keys().cloned().collect()
|
||||
} else {
|
||||
let enabled_servers: HashSet<String> =
|
||||
servers.into_iter().map(|s| s.trim().to_string()).collect();
|
||||
servers.split(',').map(|s| s.trim().to_string()).collect();
|
||||
config
|
||||
.mcp_servers
|
||||
.keys()
|
||||
@@ -754,7 +754,7 @@ mod tests {
|
||||
#[test]
|
||||
fn resolve_all_returns_all_configured_servers() {
|
||||
let registry = make_registry_with_config(&["github", "slack", "jira"]);
|
||||
let mut ids = registry.resolve_server_ids(Some(vec!["all".to_string()]));
|
||||
let mut ids = registry.resolve_server_ids(Some("all".to_string()));
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec!["github", "jira", "slack"]);
|
||||
}
|
||||
@@ -762,8 +762,7 @@ mod tests {
|
||||
#[test]
|
||||
fn resolve_comma_separated_returns_matching_servers() {
|
||||
let registry = make_registry_with_config(&["github", "slack", "jira"]);
|
||||
let mut ids =
|
||||
registry.resolve_server_ids(Some(vec!["github".to_string(), "jira".to_string()]));
|
||||
let mut ids = registry.resolve_server_ids(Some("github, jira".to_string()));
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec!["github", "jira"]);
|
||||
}
|
||||
@@ -771,7 +770,7 @@ mod tests {
|
||||
#[test]
|
||||
fn resolve_single_server_name() {
|
||||
let registry = make_registry_with_config(&["github", "slack"]);
|
||||
let ids = registry.resolve_server_ids(Some(vec!["slack".to_string()]));
|
||||
let ids = registry.resolve_server_ids(Some("slack".to_string()));
|
||||
assert_eq!(ids, vec!["slack"]);
|
||||
}
|
||||
|
||||
@@ -785,32 +784,28 @@ mod tests {
|
||||
#[test]
|
||||
fn resolve_no_config_returns_empty() {
|
||||
let registry = McpRegistry::default();
|
||||
let ids = registry.resolve_server_ids(Some(vec!["all".to_string()]));
|
||||
let ids = registry.resolve_server_ids(Some("all".to_string()));
|
||||
assert!(ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_nonexistent_server_filtered_out() {
|
||||
let registry = make_registry_with_config(&["github"]);
|
||||
let ids = registry
|
||||
.resolve_server_ids(Some(vec!["github".to_string(), "nonexistent".to_string()]));
|
||||
let ids = registry.resolve_server_ids(Some("github, nonexistent".to_string()));
|
||||
assert_eq!(ids, vec!["github"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_all_nonexistent_returns_empty() {
|
||||
let registry = make_registry_with_config(&["github"]);
|
||||
let ids = registry.resolve_server_ids(Some(vec!["foo".to_string(), "bar".to_string()]));
|
||||
let ids = registry.resolve_server_ids(Some("foo, bar".to_string()));
|
||||
assert!(ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_trims_whitespace() {
|
||||
let registry = make_registry_with_config(&["github", "slack"]);
|
||||
let mut ids = registry.resolve_server_ids(Some(vec![
|
||||
" github ".to_string(),
|
||||
" slack ".to_string(),
|
||||
]));
|
||||
let mut ids = registry.resolve_server_ids(Some(" github , slack ".to_string()));
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec!["github", "slack"]);
|
||||
}
|
||||
|
||||
+6
-8
@@ -503,7 +503,7 @@ pub async fn run_repl_command(
|
||||
Some((name, text)) => {
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
let role = ctx.retrieve_role(app.as_ref(), name.trim())?;
|
||||
let input = Input::from_str(ctx, text, Some(role))?;
|
||||
let input = Input::from_str(ctx, text, Some(role));
|
||||
ask(ctx, abort_signal.clone(), input, false).await?;
|
||||
}
|
||||
None => {
|
||||
@@ -654,7 +654,7 @@ pub async fn run_repl_command(
|
||||
match text {
|
||||
Some(text) => {
|
||||
println!("{}", dimmed_text(&format!(">> {text}")));
|
||||
let input = Input::from_str(ctx, &text, None)?;
|
||||
let input = Input::from_str(ctx, &text, None);
|
||||
ask(ctx, abort_signal.clone(), input, true).await?;
|
||||
}
|
||||
None => {
|
||||
@@ -708,8 +708,6 @@ 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 {})",
|
||||
@@ -824,7 +822,7 @@ pub async fn run_repl_command(
|
||||
None => bail!("Unable to regenerate the response"),
|
||||
};
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
input.set_regenerate(ctx.extract_role(&app)?);
|
||||
input.set_regenerate(ctx.extract_role(&app));
|
||||
ask(ctx, abort_signal.clone(), input, true).await?;
|
||||
}
|
||||
".set" => match args {
|
||||
@@ -946,7 +944,7 @@ pub async fn run_repl_command(
|
||||
},
|
||||
None => {
|
||||
reset_continuation(ctx);
|
||||
let input = Input::from_str(ctx, line, None)?;
|
||||
let input = Input::from_str(ctx, line, None);
|
||||
ask(ctx, abort_signal.clone(), input, true).await?;
|
||||
}
|
||||
}
|
||||
@@ -1042,7 +1040,7 @@ async fn ask(
|
||||
|
||||
format!("{prompt}\n\n{todo_state}")
|
||||
};
|
||||
let continuation_input = Input::from_str(ctx, &full_prompt, None)?;
|
||||
let continuation_input = Input::from_str(ctx, &full_prompt, None);
|
||||
ask(ctx, abort_signal, continuation_input, false).await
|
||||
} else {
|
||||
reset_continuation(ctx);
|
||||
@@ -1115,7 +1113,7 @@ async fn ask(
|
||||
|
||||
format!("{prompt}\n\n{todo_state}")
|
||||
};
|
||||
let continuation_input = Input::from_str(ctx, &full_prompt, None)?;
|
||||
let continuation_input = Input::from_str(ctx, &full_prompt, None);
|
||||
return ask(ctx, abort_signal, continuation_input, false).await;
|
||||
}
|
||||
}
|
||||
|
||||
+26
-67
@@ -1,9 +1,6 @@
|
||||
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;
|
||||
@@ -17,13 +14,10 @@ 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 {
|
||||
@@ -33,54 +27,22 @@ pub struct Vault {
|
||||
pub type GlobalVault = Arc<Vault>;
|
||||
|
||||
impl Vault {
|
||||
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),
|
||||
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()
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self { provider })
|
||||
}
|
||||
|
||||
pub fn default_local() -> Self {
|
||||
Self {
|
||||
provider: SupportedProvider::Local {
|
||||
provider_def: LocalProvider {
|
||||
password_file: Some(AppConfig::default().vault_password_file()),
|
||||
git_branch: None,
|
||||
..LocalProvider::default()
|
||||
},
|
||||
provider_def: local_provider,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(config: &AppConfig) -> Result<Self> {
|
||||
pub fn init(config: &AppConfig) -> Self {
|
||||
let mut provider = match &config.secrets_provider {
|
||||
Some(p) => p.clone(),
|
||||
None => SupportedProvider::Local {
|
||||
@@ -92,10 +54,11 @@ impl Vault {
|
||||
};
|
||||
|
||||
if let SupportedProvider::Local { provider_def } = &mut provider {
|
||||
ensure_password_file_initialized(provider_def)?;
|
||||
ensure_password_file_initialized(provider_def)
|
||||
.expect("Failed to initialize password file");
|
||||
}
|
||||
|
||||
Ok(Self { provider })
|
||||
Self { provider }
|
||||
}
|
||||
|
||||
pub fn local_password_file(&self) -> Result<PathBuf> {
|
||||
@@ -199,46 +162,42 @@ impl Vault {
|
||||
SupportedProvider::AwsSecretsManager { .. } => Some(
|
||||
"Try `aws sso login` (for SSO setups) or `aws configure` (for static keys), then retry.",
|
||||
),
|
||||
SupportedProvider::GcpSecretManager { .. } => {
|
||||
Some("Try `gcloud auth application-default login`, then retry.")
|
||||
}
|
||||
SupportedProvider::AzureKeyVault { .. } => Some("Try `az login`, then retry."),
|
||||
SupportedProvider::Gopass { .. } => {
|
||||
Some("Make sure `gopass init` has been run and `gopass` is on your PATH.")
|
||||
}
|
||||
SupportedProvider::OnePassword { .. } => Some("Try `op signin`, then retry."),
|
||||
SupportedProvider::GcpSecretManager { .. } => Some(
|
||||
"Try `gcloud auth application-default login`, then retry.",
|
||||
),
|
||||
SupportedProvider::AzureKeyVault { .. } => Some(
|
||||
"Try `az login`, then retry.",
|
||||
),
|
||||
SupportedProvider::Gopass { .. } => Some(
|
||||
"Make sure `gopass init` has been run and `gopass` is on your PATH.",
|
||||
),
|
||||
SupportedProvider::OnePassword { .. } => Some(
|
||||
"Try `op signin`, then retry.",
|
||||
),
|
||||
SupportedProvider::Local { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_round_trip(&self) -> Result<()> {
|
||||
const PROBE_KEY: &str = "__coyote_setup_probe__";
|
||||
const PROBE_VALUE: &str = "ok";
|
||||
let probe_key = format!("coyote-setup-probe-{}", Uuid::new_v4().simple());
|
||||
|
||||
let h = Handle::current();
|
||||
let result: Result<()> = tokio::task::block_in_place(|| {
|
||||
h.block_on(async {
|
||||
self.provider_ref()
|
||||
.set_secret(&probe_key, PROBE_VALUE)
|
||||
.set_secret(PROBE_KEY, PROBE_VALUE)
|
||||
.await
|
||||
.with_context(|| "vault write probe failed")?;
|
||||
let got = self
|
||||
.provider_ref()
|
||||
.get_secret(&probe_key)
|
||||
.get_secret(PROBE_KEY)
|
||||
.await
|
||||
.with_context(|| "vault read probe failed")?;
|
||||
let _ = self.provider_ref().delete_secret(PROBE_KEY).await;
|
||||
if got != PROBE_VALUE {
|
||||
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");
|
||||
}
|
||||
|
||||
self.provider_ref()
|
||||
.delete_secret(&probe_key)
|
||||
.await
|
||||
.with_context(|| "vault delete probe failed")?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
|
||||
+10
-223
@@ -2,7 +2,6 @@ use crate::config::ensure_parent_exists;
|
||||
use crate::vault::{SECRET_RE, Vault};
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use gman::SecretError;
|
||||
use gman::providers::SupportedProvider;
|
||||
use gman::providers::aws_secrets_manager::AwsSecretsManagerProvider;
|
||||
use gman::providers::azure_key_vault::AzureKeyVaultProvider;
|
||||
@@ -13,9 +12,9 @@ use gman::providers::one_password::OnePasswordProvider;
|
||||
use indoc::formatdoc;
|
||||
use inquire::validator::Validation;
|
||||
use inquire::{Confirm, Password, PasswordDisplayMode, Select, Text, min_length, required};
|
||||
use log::debug;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use gman::SecretError;
|
||||
|
||||
pub fn ensure_password_file_initialized(local_provider: &mut LocalProvider) -> Result<()> {
|
||||
let vault_password_file = local_provider
|
||||
@@ -92,7 +91,6 @@ 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()
|
||||
@@ -164,7 +162,6 @@ 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.",
|
||||
@@ -325,7 +322,9 @@ fn advisory_preflight(label: &str, cli: &str, args: &[&str]) {
|
||||
if !stderr.trim().is_empty() {
|
||||
eprintln!(" {}", stderr.trim());
|
||||
}
|
||||
eprintln!(" Setup will continue. Fix authentication before using --add-secret etc.");
|
||||
eprintln!(
|
||||
" Setup will continue. Fix authentication before using --add-secret etc."
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
@@ -355,19 +354,6 @@ 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;
|
||||
|
||||
@@ -380,12 +366,8 @@ where
|
||||
|
||||
SECRET_RE
|
||||
.replace_all(line, |caps: &fancy_regex::Captures<'_>| {
|
||||
if fatal_error.is_some() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let name = caps[1].trim();
|
||||
match get_secret(name) {
|
||||
match vault.get_secret(name, false) {
|
||||
Ok(s) => s,
|
||||
Err(e) => match e.downcast_ref::<SecretError>() {
|
||||
Some(SecretError::NotFound { .. }) => {
|
||||
@@ -393,9 +375,10 @@ where
|
||||
String::new()
|
||||
}
|
||||
Some(SecretError::AuthFailed { .. }) => {
|
||||
let base =
|
||||
format!("Failed to fetch secret '{name}' from vault: {e}");
|
||||
let msg = match auth_hint {
|
||||
let base = format!(
|
||||
"Failed to fetch secret '{name}' from vault: {e}"
|
||||
);
|
||||
let msg = match vault.auth_hint() {
|
||||
Some(hint) => format!("{base}\n\nHint: {hint}"),
|
||||
None => base,
|
||||
};
|
||||
@@ -422,199 +405,3 @@ where
|
||||
|
||||
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