Compare commits
112
Commits
b972c12559
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b37eb4602 | ||
|
|
40996a06fe | ||
|
|
00a21ee777 | ||
|
|
016f2654e4 | ||
|
|
9a1d00f5e6 | ||
|
|
a14c768f9a | ||
|
|
c102da5665 | ||
|
|
4402d254f6 | ||
|
|
02974c8abc | ||
|
|
9f7eeb7dc6 | ||
|
|
f3d44212d5 | ||
|
|
f0661b4ea4 | ||
|
|
0946e3ec64 | ||
|
|
65e6249adb | ||
|
|
18c2cfeb41 | ||
|
|
50d2a74e80 | ||
|
|
ea001ade7a | ||
|
|
baa45970da | ||
|
|
5039b74926 | ||
|
|
176a81412a | ||
|
|
cebc32f70a | ||
|
|
d3914955e2 | ||
|
|
a8d730cf01 | ||
|
|
cf638305e6 | ||
|
|
3601faa969 | ||
|
|
e4c5f42a25 | ||
|
|
08dddb09fb | ||
|
|
f323c919f5 | ||
|
|
544a6bbbbc | ||
|
|
772c19f2bd | ||
|
|
1e15bc31d5 | ||
|
|
c170d08654 | ||
|
|
c707aaf3ee | ||
|
|
2d9829106d | ||
|
|
3d24719a71 | ||
|
|
290f6b257f | ||
|
|
e7811a6b87 | ||
|
|
94ede264e5
|
||
|
|
92c8ff8934 | ||
|
|
af697d129b | ||
|
|
895ecc812f | ||
|
|
c0224e20cb | ||
|
|
92d9464b38 | ||
|
|
a92ebb5b94 | ||
|
|
93e90106a8 | ||
|
|
35e4c82f27 | ||
|
|
5711432ac4 | ||
|
|
ee45e42013 | ||
|
|
3d640b9efa | ||
|
|
730f942bab | ||
|
|
2efeda0ba7 | ||
|
|
df62c32822 | ||
|
|
3762bbe09f | ||
|
|
ebe7816600 | ||
|
|
3ebe11a4f0 | ||
|
|
429ae3cc8e | ||
|
|
304b8f635f | ||
|
|
198c9f42df | ||
|
|
404a45a311 | ||
|
|
bfb8105682 | ||
|
|
1650196cae | ||
|
|
fa04e09373 | ||
|
|
074083af31 | ||
|
|
c376737bbd | ||
|
|
4e50b4ff4a | ||
|
|
5a9f8c42b9 | ||
|
|
2eb63cfc0d | ||
|
|
28018f33c9 | ||
|
|
6256b5fcfa | ||
|
|
24ed674952 | ||
|
|
caabf41b65 | ||
|
|
2d874f1d7c | ||
|
|
6a694d10db | ||
|
|
177d61cf94 | ||
|
|
4025b8dacd | ||
|
|
cb025b7fff | ||
|
|
fcc3756634 | ||
|
|
257b06bbd4 | ||
|
|
7cf88c030f | ||
|
|
a9df9a4dd5 | ||
|
|
7f3f95d89d | ||
|
|
bfc3b7bfea | ||
|
|
240eaa081a | ||
|
|
c7384b7a9b | ||
|
|
ed9778c07b | ||
|
|
5a32219178 | ||
|
|
9e652f7801 | ||
|
|
fcff426ae5 | ||
|
|
cb0802c2d0 | ||
|
|
d7524b8de7 | ||
|
|
aa14e66c35 | ||
|
|
3c9f443bce | ||
|
|
e55120dac6 | ||
|
|
b67f1ef854 | ||
|
|
b38562a961 | ||
|
|
40846de37a | ||
|
|
a5a3eed6d8 | ||
|
|
ad9ff3bea8 | ||
|
|
5177d95ee0 | ||
|
|
9bc37e226b | ||
|
|
a4b55d9e42 | ||
|
|
c8b00b20bc | ||
|
|
eb37f8bb46 | ||
|
|
6fade71e8c | ||
|
|
61a3cfb662 | ||
|
|
67819784b7 | ||
|
|
437512fd6d | ||
|
|
ef88b6a2c8 | ||
|
|
d68f4ecaeb | ||
|
|
01ada1da18 | ||
|
|
7caa24d090 | ||
|
|
0e941fb360 |
@@ -0,0 +1 @@
|
||||
assets/config-template.yaml text eol=lf
|
||||
@@ -166,6 +166,9 @@ jobs:
|
||||
os: ubuntu-latest
|
||||
use-cross: true
|
||||
cargo-flags: ''
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-24.04-arm
|
||||
cargo-flags: ''
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-latest
|
||||
use-cross: true
|
||||
@@ -253,7 +256,7 @@ jobs:
|
||||
run: echo "BUILD_CMD=cross" >> $GITHUB_ENV
|
||||
|
||||
- name: Install latest LLVM/Clang
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
wget https://apt.llvm.org/llvm.sh
|
||||
chmod +x llvm.sh
|
||||
|
||||
+199
@@ -1,3 +1,202 @@
|
||||
## v0.10.1 (2026-08-31)
|
||||
|
||||
### Fix
|
||||
|
||||
- make binary shims for custom tools cross-device compatible so users can't accidentally break sandboxes via `sbx cp ~/.config/coyote <sbx-name>:/home/agent/.config`
|
||||
|
||||
## v0.10.0 (2026-08-29)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- invocations using --prompt <text> must switch to
|
||||
--temp-role <text>; clap rejects the old flag loudly.
|
||||
|
||||
### Feat
|
||||
|
||||
- Support Linux ARM64 GNU
|
||||
- Note that duckdb RAG driver is unavailable on MUSL Linux builds when initializing a RAG so users know when using the binary, not just based on docs
|
||||
- updated coyote installation scripts to detect when gnu is installable
|
||||
- upgraded to v2 mixin schema
|
||||
- improved first-time run experience and included a templated configuration file that now has comments like the config.example.yaml so users don't have to go to the repo to see all the knobs
|
||||
- **TASK-005**: carry the quality bar through sisyphus and architect
|
||||
- **TASK-004**: reviewer routing — code-reviewer quality-bar resolution, domain linter pass, surface-skill routing, rigor folding; file-reviewer surface-skill whitelist + convention/correctness marker
|
||||
- **TASK-003**: add surface skills batch B — worker-review, iac-review, migration-review, cicd-review
|
||||
- **TASK-002**: add surface skills batch A — rest-api-review, cli-review, library-review
|
||||
- **TASK-001**: add rigor/surfaces declaration layer to planning skills
|
||||
- **repl**: color .info mcp-server verdicts and complete only running servers
|
||||
- **cli**: rename mcp_config asset category to mcp-config with snake_case alias
|
||||
- create a new probe agent to probe code verifications using usage-pattern testing
|
||||
- Add in the web_search_coyote tool where helpful
|
||||
- **mcp**: add .info mcp-server, .set mcp_tools, and filtered-server listing to the REPL
|
||||
- **mcp**: enforce per-server tool allowlists across runtime, jobs, agents, and graph nodes
|
||||
- **mcp**: add mcp_tools allowlist config surfaces across roles, sessions, agents, graphs, and skills
|
||||
- **mcp**: add per-server tool allowlist policy module and allowedTools config field
|
||||
- support mcp.json in the root of bundle repos as well as in the legacy functions directory
|
||||
- prefer ~/.config/coyote/mcp.json for the user-scope MCP config
|
||||
- claude and openai native web search via web_search_coyote
|
||||
- **jobs**: node-local job ownership and capability-gated job__* visibility
|
||||
- **graph**: support max_concurrent_jobs at the graph level
|
||||
- **jobs**: allow uncapped collect via full_result
|
||||
- **jobs**: exempt polling tools from loop tracker and hint on unchanged checks
|
||||
- **supervisor**: push agent completion notifications to the spawning context
|
||||
- **jobs**: push background-job completion notifications via per-context queue
|
||||
- inject background-jobs prompt guidance when jobs are enabled
|
||||
- add background job runner, job__* handlers, and start gates
|
||||
- generalize supervisor registry to TaskHandle enum with job scaffolding, kill discipline, and max_concurrent_jobs config
|
||||
- run an advisory observability pass after implementation in sisyphus
|
||||
- add an observability-review skill for post-implementation monitoring analysis
|
||||
- check under- and over-logging in the code review gate
|
||||
- calibrate logging registers in the code-writing agents
|
||||
- add a logging-discipline skill for calibrating log output to repo conventions
|
||||
- **cli**: rename --prompt to --temp-role
|
||||
- complete --filter and --force on the first .install argument
|
||||
- **mcp**: bound tool-result passthrough and surface resource audience annotations
|
||||
- **mcp**: add mcp_prompt meta-tool and harden prompt display rendering
|
||||
- **repl**: add .prompt command with live staged tab-completion
|
||||
- **mcp**: add mcp_read meta-tool for resource reads
|
||||
- **mcp**: gate meta-function emission on advertised server capabilities
|
||||
- **mcp**: add render.rs content policy (text paging, pattern filter, blob spill)
|
||||
- **mcp**: extend the server catalog to resources, templates, and prompts
|
||||
- complete --filter and --force on the first .install argument
|
||||
- run design interviews as grilling frontier rounds across the planning agents
|
||||
- add a grilling skill for frontier-round design interviews
|
||||
- add an on-demand architecture-reviewer agent for deepening scans
|
||||
- add a codebase-design skill with the deep-module design vocabulary
|
||||
- add a feedback-loop-first diagnosing-bugs skill to the coding suite
|
||||
- add a Fowler code-smell baseline to the code-review skill
|
||||
- flag duplicate helpers in code reviews with a repo-wide DRY check
|
||||
- add a transactional-integrity review skill to the code review gate
|
||||
- add an operational-history prior-art lane to the code-reviewer agent
|
||||
- support name=value macro arguments with variable tab completion
|
||||
- add --help guides to the .install and .uninstall REPL commands
|
||||
- expand owner/repo shorthand for --install with a --git-host flag
|
||||
- remove the deprecated --install-from flag and .install remote form
|
||||
- rename install flags and unify .install dispatch
|
||||
- add --uninstall and .uninstall for installed bundles
|
||||
- add --update-bundle with provenance-aware conflict handling
|
||||
- add --list-bundles and .list bundles with drift detection
|
||||
- record bundle provenance when installing from remote repos
|
||||
- add bundle provenance store
|
||||
- parse bundle manifests and capture resolved SHAs for remote installs
|
||||
- created a new comment-discipline skill for the built in sisyphus suite
|
||||
- created a dedicated git_command tool to tighten tool calling permissions in the git-master skill
|
||||
- Added a new security review step to the code writing quality gates
|
||||
- addressed review comments
|
||||
- add --no-workspace-macros opt-out for workspace macro loading
|
||||
- execute non-isolated macros on the live REPL context
|
||||
- surface macros as first-class custom commands in the REPL
|
||||
- add lazy macro resolver with two-dir discovery and per-macro states
|
||||
- add enabled_macros config field at global, role, agent, and session levels
|
||||
- add description and isolated fields to Macro struct
|
||||
- Dynamically detect RAG embedding model dimension for any given model
|
||||
- Support auto confirmation for gatekeeper agents
|
||||
- retry LLM API calls once after 401 by force-refreshing the OAuth token
|
||||
- identity-aware rejected-token marker for LLM OAuth cache
|
||||
- typed ApiStatusError carrying HTTP status through LLM client errors
|
||||
- per-request OAuth token injection with mid-session refresh for HTTP MCP servers
|
||||
- reason-specific warnings for MCP servers that fail OAuth at startup
|
||||
- Installed duckdb into the coyote image
|
||||
- Support managing MCP servers from the CLI directly
|
||||
- append new built-in rag__query function to RAG contexts to allow further querying by LLMs
|
||||
- support static file bundling with sbx-mixins
|
||||
- **rag**: offer the storage driver when an agent initializes its RAG
|
||||
- simplified the duckdb selection prompt
|
||||
- **rag**: let several Coyote processes query one duckdb RAG at once
|
||||
- **rag**: support string and UUID Qdrant point IDs
|
||||
- **rag**: create the API key secret inline in the attach wizard
|
||||
- let workflow rag nodes select a RAG driver
|
||||
- improved wording and heuristic detection for sisyphus suite of agents
|
||||
- upgraded to sbx kit v2 spec for improved integration
|
||||
- **rag**: add attach-only Qdrant provider, attach wizard and sandbox wiring
|
||||
- **rag**: add DuckDB provider behind the RAG driver abstraction
|
||||
- **rag**: add driver/attached fields, validation floors and force-reingest
|
||||
- **mcp**: send RFC 8707 resource indicator in OAuth flows
|
||||
- Added loaded indicators to .list tools/mcp-servers/skills
|
||||
- hide .recover from tab completions when no session is active
|
||||
- add a new .recover command for sessions to recover from errors
|
||||
- integrated the git-ssh-sign kit into the coyote sandbox kit
|
||||
|
||||
### Fix
|
||||
|
||||
- Harden install scripts: detect libssl3 without ldconfig on PATH, survive noexec tmp dirs, and guard against partial curl|bash execution
|
||||
- ldconfig on Debian-based distros lives in /usr/sbin
|
||||
- Prefer GNU linux builds in install scripts for duckdb support, and gate duckdb support on linux hosts that are MUSL until MUSL support is added
|
||||
- **mcp**: omit declared-but-empty prompt/resource capabilities from .info mcp-server
|
||||
- **mcp**: annotate declared-but-empty prompt/resource capabilities in .info mcp-server
|
||||
- also support the .continue edge case for session crashing checkpointing
|
||||
- checkpoint sessions that crash for easy resuming
|
||||
- job__check ring buffer also collects file output for LLM_OUTPUT as well as stdout
|
||||
- **tools**: interactive-shell semantics and stderr capture in execute_command
|
||||
- **function**: gate test-only declaration appender behind cfg(test)
|
||||
- **graph**: gate unix-only test imports behind cfg(unix)
|
||||
- harden job runner lifecycle and whitelist conformance
|
||||
- **function**: floor tool-output truncation cut to a UTF-8 char boundary
|
||||
- **supervisor**: make agent__check a pure status probe that never consumes the handle
|
||||
- **supervisor**: surface finished-but-uncollected tasks in turn-end guardrail
|
||||
- fix grep "binary" errors when searching UTF-8 files with unicode characters like some of the Coyote source
|
||||
- **bundles**: harden the install pipeline for cross-platform correctness
|
||||
- **mcp**: harden the spill path for cross-platform correctness
|
||||
- **repl**: offer prompts in .list tab completion and rename its listing helpers
|
||||
- **mcp**: gate unix-only spill permission APIs for windows builds
|
||||
- harden the bundle lifecycle per code review
|
||||
- address code review findings on the bundle lifecycle
|
||||
- reserve category names, confirm fork-name collisions, report secrets on uninstall
|
||||
- make bundle provenance portable to Windows
|
||||
- user__ask should have been renamed to user__select in graph agent user interaction invocations
|
||||
- support tab completions for graph-based agents with variables as well as standard agents
|
||||
- surface macro parse errors on top-level invocation
|
||||
- render .list macros as a comfy-table instead of fixed-width columns
|
||||
- drain crossterm characters in zellij in kitty contexts to prevent DA1 responses from entering prompt
|
||||
- drain tty input when displaying inquire prompts to prevent unintentional escapes
|
||||
- breaking iwe MCP server changes with newest version
|
||||
- improved handling of non service-specific secrets using sbx custom-secrets
|
||||
- include tool output to LLM_OUTPUT in errors as well as stderr
|
||||
- prevent tmp-overwriting
|
||||
- Corrected a rare edge case on how tool files are generated during parallel executions
|
||||
- cosmetic fix after improved bash tool handling
|
||||
- Improved subagent escalation handling
|
||||
- latent parsing bugs in fs_patch and argc
|
||||
- Prevent infinite hangs in coder agent and implement timeouts for LLM API calls and interactive tools
|
||||
- allow nested italics inside bold spans in markdown renderer
|
||||
- properly handle OAuth refreshes
|
||||
- correct newline removal from fs_write and fs_patch
|
||||
- detect duplicate tool call IDs client-side before sending to Claude
|
||||
- **rag**: warn when a duckdb store is empty but files are indexed
|
||||
- **rag**: keep a local Qdrant off an ambient proxy
|
||||
- keep loopback and LAN traffic off an ambient proxy
|
||||
- **rag**: stop routing Qdrant requests through an ambient proxy
|
||||
- **rag**: treat a zero min_score as no floor on Qdrant searches
|
||||
- **rag**: address Copilot review findings on the driver abstraction
|
||||
- **rag**: delete the DuckDB write-ahead log alongside the store
|
||||
- **sandbox**: discover agent-scoped RAG mixin sidecars
|
||||
- **rag**: stop the attach wizard from silently accepting an empty collection
|
||||
- **rag**: fail loudly when a RAG's vault secret is missing
|
||||
- **rag**: serialize DuckDB extension installs to stop a Windows race
|
||||
- **rag**: emit an sbx kit v2 mixin and declare RAG credentials to the proxy
|
||||
- **rag**: install DuckDB vss and fts extensions when they are missing
|
||||
- don't output thinking blocks for claude-based models
|
||||
- additional edge case fix for duplicate tool call IDs in anthropic API calls
|
||||
- removed temperature modifier in librarian agent to mitigate invisible errors
|
||||
- strip reasoning blocks for structured LLM output in graph agents
|
||||
- prevent rare duplicate tool call IDs in long running claude prompts
|
||||
- agents inherit global reasoning effort if unset
|
||||
|
||||
### Refactor
|
||||
|
||||
- **function**: finish supervisor-to-agent vocabulary migration
|
||||
- **function**: rename agent-tool symbols out of supervisor vocabulary
|
||||
- Modified the naming of several generalized supervisor values
|
||||
- pulled out some imports to clean up the MCP render module a bit
|
||||
- **mcp**: centralize meta-function prefix predicates and fix list_tools pagination
|
||||
- Refactored some bundle const locations
|
||||
- drop the .install remote migration hint
|
||||
- drop the --install-from tombstone entirely
|
||||
- render .list agents and .list skills as comfy-tables
|
||||
- **rag**: discover driver_config secrets by grammar, not field name
|
||||
- **rag**: drop the hardcoded embedding model hint from attach
|
||||
- **rag**: interpolate every driver_config value, not just api_key
|
||||
- **rag**: extract RagProvider trait and add YamlProvider
|
||||
|
||||
## v0.8.3 (2026-08-03)
|
||||
|
||||
### Fix
|
||||
|
||||
Generated
+197
-162
File diff suppressed because it is too large
Load Diff
+16
-12
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "coyote-ai"
|
||||
version = "0.8.3"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
|
||||
description = "An all-in-one, batteries included LLM CLI Tool"
|
||||
description = "The batteries-included runtime for LLMs"
|
||||
keywords = ["chatgpt", "llm", "cli", "ai", "repl"]
|
||||
homepage = "https://github.com/Dark-Alex-17/coyote"
|
||||
repository = "https://github.com/Dark-Alex-17/coyote"
|
||||
@@ -51,7 +51,13 @@ textwrap = "0.16.0"
|
||||
ansi_colours = "1.2.2"
|
||||
eventsource-stream = "0.2.3"
|
||||
log = "0.4.28"
|
||||
log4rs = { version = "1.4.0", features = ["file_appender", "rolling_file_appender", "compound_policy", "fixed_window_roller", "size_trigger"] }
|
||||
log4rs = { version = "1.4.0", features = [
|
||||
"file_appender",
|
||||
"rolling_file_appender",
|
||||
"compound_policy",
|
||||
"fixed_window_roller",
|
||||
"size_trigger",
|
||||
] }
|
||||
shell-words = "1.1.0"
|
||||
sha2 = "0.10.8"
|
||||
unicode-width = "0.2.0"
|
||||
@@ -113,14 +119,7 @@ qrcode = "0.14"
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "0.13.3"
|
||||
features = [
|
||||
"json",
|
||||
"multipart",
|
||||
"stream",
|
||||
"form",
|
||||
"socks",
|
||||
"rustls",
|
||||
]
|
||||
features = ["json", "multipart", "stream", "form", "socks", "rustls"]
|
||||
default-features = false
|
||||
|
||||
[dependencies.syntect]
|
||||
@@ -139,8 +138,13 @@ arboard = { version = "3.3.0", default-features = false, features = [
|
||||
[target.'cfg(not(any(target_os = "linux", target_os = "android", target_os = "emscripten")))'.dependencies]
|
||||
arboard = { version = "3.3.0", default-features = false }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
ctor = "1.0.13"
|
||||
pretty_assertions = "1.4.0"
|
||||
rmcp = { version = "3.1.2", features = ["server"] }
|
||||
serial_test = "3"
|
||||
|
||||
[[bin]]
|
||||
@@ -150,4 +154,4 @@ path = "src/main.rs"
|
||||
[profile.release]
|
||||
lto = true
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
opt-level = "z"
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ LABEL com.docker.sandboxes="templates" \
|
||||
com.docker.sandboxes.flavor="shell-docker" \
|
||||
com.docker.sandboxes.start-docker="true" \
|
||||
org.opencontainers.image.title="coyote" \
|
||||
org.opencontainers.image.description="An all-in-one, batteries-included LLM CLI tool: Shell Assistant, CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros." \
|
||||
org.opencontainers.image.description="The batteries-included runtime for LLMs: Shell Assistant, CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros." \
|
||||
org.opencontainers.image.source="https://github.com/Dark-Alex-17/coyote" \
|
||||
org.opencontainers.image.version="${COYOTE_VERSION}"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Coyote: All-in-one, batteries-included LLM CLI Tool
|
||||
# Coyote: The batteries-included runtime for LLMs
|
||||
|
||||

|
||||
[](https://crates.io/crates/coyote-ai)
|
||||
@@ -6,14 +6,18 @@
|
||||

|
||||
[](https://github.com/Dark-Alex-17/coyote/releases)
|
||||

|
||||
[](./LICENSE)
|
||||
[](https://github.com/Dark-Alex-17/coyote/blob/main/LICENSE)
|
||||
|
||||
Coyote is an all-in-one, batteries-included, LLM CLI tool featuring Shell Assistant, CLI & REPL Mode, RAG, AI Tools &
|
||||
Agents, and More.
|
||||
Coyote is an **all-in-one, batteries-included LLM runtime** for building, running, and interacting with AI from your terminal.
|
||||
It brings together a Shell Assistant, CLI & REPL modes, RAG, tools, agents, MCP, skills, sandboxes, multi-agent workflows, and
|
||||
more in a single runtime.
|
||||
|
||||
Coyote comes ready to use with built-in agents, roles, macros, and tools, so you can get started without assembling an AI
|
||||
stack from scratch. When you want to extend it, entire bundles of agents, roles, macros, tools, MCP servers, and other
|
||||
configurations can be installed directly from any Git repository.
|
||||
|
||||
See [Bundles](https://github.com/Dark-Alex-17/coyote/wiki/Bundles) to learn how to create, install, and share Coyote bundles.
|
||||
|
||||
It is designed to include a number of useful agents, roles, macros, and more so users can get up and running with Coyote
|
||||
in as little time as possible. You can also install entire bundles of agents, roles, macros, tools, and MCP servers from
|
||||
any git repository. See [Sharing Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Sharing-Configurations) for more information.
|
||||
|
||||

|
||||
|
||||
@@ -23,7 +27,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
|
||||
* [AIChat Migration Guide](https://github.com/Dark-Alex-17/coyote/wiki/AIChat-Migration): Coming from AIChat? Follow the migration guide to get started.
|
||||
* [Installation](#install): Install Coyote
|
||||
* [Getting Started](#getting-started): Get started with Coyote by doing first-run setup steps.
|
||||
* [Sharing Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Sharing-Configurations): Install bundles of agents, roles, skills, macros, tools, and MCP servers from any git repo, and share your own. Bundles are Coyote's equivalent of plugins in other CLI agents.
|
||||
* [Bundles](https://github.com/Dark-Alex-17/coyote/wiki/Bundles): Install bundles of agents, roles, skills, macros, tools, and MCP servers from any git repo, and share your own. Bundles are Coyote's equivalent of plugins in other CLI agents.
|
||||
* [REPL](https://github.com/Dark-Alex-17/coyote/wiki/REPL): Interactive Read-Eval-Print Loop for conversational interactions with LLMs and Coyote.
|
||||
* [Custom REPL Prompt](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt): Customize the REPL prompt to provide useful contextual information.
|
||||
* [Vault](https://github.com/Dark-Alex-17/coyote/wiki/Vault): Securely store and manage sensitive information such as API keys and credentials.
|
||||
@@ -35,10 +39,12 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
|
||||
* [Create Custom TypeScript Tools](https://github.com/Dark-Alex-17/coyote/wiki/Custom-Tools#custom-typescript-based-tools)
|
||||
* [Create Custom Bash Tools](https://github.com/Dark-Alex-17/coyote/wiki/Custom-Bash-Tools)
|
||||
* [Bash Prompt Utilities](https://github.com/Dark-Alex-17/coyote/wiki/Bash-Prompt-Helpers)
|
||||
* [First-Class MCP Server Support](https://github.com/Dark-Alex-17/coyote/wiki/MCP-Servers): Easily connect and interact with MCP servers for advanced functionality.
|
||||
* [First-Class MCP Server Support](https://github.com/Dark-Alex-17/coyote/wiki/MCP-Servers): Easily connect and interact with MCP servers for advanced functionality. Coyote supports all three MCP capabilities: tools, resources, and prompts.
|
||||
* Models interact with each server through a compact set of capability-gated meta-tools: `mcp_search`/`mcp_describe` for discovery across tools, resources, and prompts, `mcp_invoke` for tool calls, `mcp_read` for paged and regex-filterable resource reads, and `mcp_prompt` for server-defined prompts. Binary content is spilled to disk instead of inlined, and oversized tool results are bounded before they reach the model.
|
||||
* Invoke server prompts yourself with `.prompt <server> <name> [key=value ...]` in the REPL, with live staged tab-completion (servers, then prompt names, then `key=` arguments), and discover them with `.list prompts`.
|
||||
* [Macros](https://github.com/Dark-Alex-17/coyote/wiki/Macros): Automate repetitive tasks and workflows with Coyote "scripts" (macros). Macros are Coyote's custom commands: invoke any macro directly by name (e.g. `.review main`), with tab-completion, right alongside the built-in REPL commands.
|
||||
* Give a macro a `description` (shown in `.list macros` and completions) and set `isolated: false` to run its steps on the live session, exactly as if you typed them. Note that non-isolated steps are recorded in the session, and mutating steps (`.role`, `.model`, ...) persist after the macro ends — by design. Steps are fail-fast: an error aborts the remaining steps, but completed steps' effects remain. A non-isolated macro step cannot invoke another macro, and a `.exit` step never exits the REPL.
|
||||
* Commit project-specific macros to `.coyote/macros/` in your repo — they shadow same-named global macros (opt out with `--no-workspace-macros`).
|
||||
* Give a macro a `description` (shown in `.list macros` and completions) and set `isolated: false` to run its steps on the live session, exactly as if you typed them. Note that non-isolated steps are recorded in the session, and mutating steps (`.role`, `.model`, ...) persist after the macro ends, by design. Steps are fail-fast: an error aborts the remaining steps, but completed steps' effects remain. A non-isolated macro step cannot invoke another macro, and a `.exit` step never exits the REPL.
|
||||
* Commit project-specific macros to `.coyote/macros/` in your repo. They shadow same-named global macros (opt out with `--no-workspace-macros`).
|
||||
* Pass variables positionally or by name: leading `name=value` args set declared variables directly (letting earlier variables keep their defaults), and remaining args fill the rest in order. Tab completion after a macro name lists each variable with its description and default.
|
||||
* Scope which macros are invocable with `enabled_macros` in the global config, a role, an agent, or a session (most specific wins; an empty list disables all macros), and toggle at runtime with `.macro enable|disable <name>`.
|
||||
* [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation.
|
||||
@@ -49,6 +55,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
|
||||
* [Skills](https://github.com/Dark-Alex-17/coyote/wiki/Skills): Modular knowledge or capability packs the LLM can load and unload mid-conversation. Multiple skills compose; instructions stack, tools and MCPs union.
|
||||
* [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools.
|
||||
* [Graph Agents](https://github.com/Dark-Alex-17/coyote/wiki/Graph-Agents): Define an agent as a declarative, YAML-driven workflow. A directed graph of typed nodes (LLM calls, scripts, approvals, user input, RAG retrieval, sub-agent spawns).
|
||||
* [Background Jobs](https://github.com/Dark-Alex-17/coyote/wiki/Background-Jobs): Run long tool calls (builds, test suites, slow MCP calls) in the background with the `job__*` tools while the model keeps working, and completion arrives as a push notification.
|
||||
* [Todo System](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System): Built-in task tracking for improved LLM reliability with smaller models.
|
||||
* [Environment Variables](https://github.com/Dark-Alex-17/coyote/wiki/Environment-Variables): Override and customize your Coyote configuration at runtime with environment variables.
|
||||
* [Client Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Clients): Configuration instructions for various LLM providers.
|
||||
@@ -75,7 +82,7 @@ Coyote requires the following tools to be installed on your system:
|
||||
* **npm:** `npm i -g @ast-grep/cli`
|
||||
* Optional: if `ast-grep` is not installed, the `ast_grep` tool reports it and agents fall back to `fs_grep`
|
||||
* [duckdb](https://duckdb.org/) (for fast, local RAGs)
|
||||
* `curl https://install.duckdb.org | sh`
|
||||
* `curl https://install.duckdb.org | bash`
|
||||
|
||||
These tools are used to provide various functionalities within Coyote, such as document processing, JSON manipulation,
|
||||
etc., and they are used within agents and tools.
|
||||
@@ -260,7 +267,7 @@ coyote | Out-String | Invoke-Expression
|
||||
|
||||
### Shell Integration
|
||||
You can integrate Coyote's Shell Assistant into your shell for enhanced command-line assistance. Add the code in the
|
||||
corresponding [shell integration script](./scripts/shell-integration) to your shell. Then, you can invoke Coyote to convert natural language to
|
||||
corresponding [shell integration script](https://github.com/Dark-Alex-17/coyote/tree/main/scripts/shell-integration) to your shell. Then, you can invoke Coyote to convert natural language to
|
||||
shell commands by pressing `Alt-e`. For example:
|
||||
|
||||
```shell
|
||||
@@ -278,7 +285,7 @@ coyote --info | grep 'config_file' | awk '{print $2}'
|
||||
```
|
||||
|
||||
The configuration file consists of a number of settings. To see a full example configuration file with every setting
|
||||
defined, refer to the [example configuration file](./config.example.yaml).
|
||||
defined, refer to the [example configuration file](https://github.com/Dark-Alex-17/coyote/blob/main/config.example.yaml).
|
||||
|
||||
### Default LLM
|
||||
The following settings are available to configure the default LLM that is used when you start Coyote, and its
|
||||
@@ -332,7 +339,7 @@ The appearance of Coyote can be modified using the following settings:
|
||||
|
||||
Coyote began as a fork of [AIChat CLI](https://github.com/sigoden/aichat) and has since evolved into an independent project.
|
||||
|
||||
See [CREDITS.md](./CREDITS.md) for full attribution and background.
|
||||
See [CREDITS.md](https://github.com/Dark-Alex-17/coyote/blob/main/CREDITS.md) for full attribution and background.
|
||||
|
||||
---
|
||||
|
||||
@@ -343,11 +350,11 @@ See [CREDITS.md](./CREDITS.md) for full attribution and background.
|
||||
|
||||
## License
|
||||
|
||||
Coyote is licensed under the [GNU Affero General Public License v3.0](./LICENSE)
|
||||
Coyote is licensed under the [GNU Affero General Public License v3.0](https://github.com/Dark-Alex-17/coyote/blob/main/LICENSE)
|
||||
(AGPL-3.0-only).
|
||||
|
||||
Coyote began as a fork of [AIChat](https://github.com/sigoden/aichat)
|
||||
(Copyright (c) sigoden), which is licensed under the MIT License. Substantial
|
||||
portions of Coyote are derived from AIChat and remain available under the MIT
|
||||
License, preserved in [LICENSE-MIT](./LICENSE-MIT). See [NOTICE](./NOTICE) and
|
||||
[CREDITS.md](./CREDITS.md) for details.
|
||||
License, preserved in [LICENSE-MIT](https://github.com/Dark-Alex-17/coyote/blob/main/LICENSE-MIT). See [NOTICE](https://github.com/Dark-Alex-17/coyote/blob/main/NOTICE) and
|
||||
[CREDITS.md](https://github.com/Dark-Alex-17/coyote/blob/main/CREDITS.md) for details.
|
||||
|
||||
@@ -3,8 +3,9 @@ description: |
|
||||
Design-doc orchestrator for any project. Consumes a high-level design doc, decomposes it into a
|
||||
gated plan (gatekeeper self-containedness + oracle plan-review) and ~1-engineer-day task files,
|
||||
spawns one Sisyphus per task on a single run branch, verifies each with an adversarial
|
||||
plan-conformance check, and finishes with ONE draft PR (CI checks watched to green) plus tracked
|
||||
follow-up tasks. Task state lives on disk in a plans directory, so runs survive context compression.
|
||||
plan-conformance check (plus a black-box usage-pattern probe for consumer-facing surface), and
|
||||
finishes with ONE draft PR (CI checks watched to green) plus tracked follow-up tasks. Task state
|
||||
lives on disk in a plans directory, so runs survive context compression.
|
||||
version: 2.1.0
|
||||
agent_session: temp
|
||||
auto_continue: true
|
||||
@@ -18,6 +19,7 @@ spawnable_agents:
|
||||
- explore
|
||||
- librarian
|
||||
- adversary
|
||||
- probe
|
||||
- gatekeeper
|
||||
max_concurrent_agents: 10
|
||||
max_agent_depth: 10
|
||||
@@ -135,6 +137,15 @@ instructions: |
|
||||
Open questions, and a **Task breakdown** where **each task is sized to ~1 engineer-day** (decompose
|
||||
anything bigger NOW).
|
||||
|
||||
Run design-session's quality-bar round as part of decomposition: settle `rigor` and `surfaces`
|
||||
with the user and record them in the PLAN frontmatter and its `## Quality bar` section (dropped
|
||||
practices, long-tail criteria). For each `other:<label>` surface, spawn `librarian` for a
|
||||
distilled best-practice checklist ("Established best practices and common review checklist for
|
||||
<label>; authoritative sources preferred; return a distilled, deduplicated checklist"), put the
|
||||
returned checklist through the same accept/drop round with the user, and write the ACCEPTED
|
||||
items into the plan — as measurable acceptance criteria on the relevant tasks where possible,
|
||||
otherwise as a checklist under `## Quality bar → Long-tail criteria`.
|
||||
|
||||
Ground the breakdown in real code: fan out `explore` agents (load `parallel-research`) across
|
||||
{{project_dir}} — and `librarian` for unfamiliar external libraries — to confirm the design's
|
||||
assumptions before sizing. Do NOT guess file/symbol names — verify them.
|
||||
@@ -149,7 +160,8 @@ instructions: |
|
||||
|
||||
**Gate 1 — Self-containedness (`gatekeeper`).** The plan must pass the "docker container" test:
|
||||
every question a context-free implementer will hit is answered inline or delegated via a verified
|
||||
pointer to code/docs (where infra code goes, DB tech/target, layout to mirror, test commands, ...).
|
||||
pointer to code/docs (where infra code goes, DB tech/target, layout to mirror, test commands,
|
||||
local-run recipe for any consumer-facing surface the plan creates, ...).
|
||||
|
||||
> `agent__spawn --agent gatekeeper --prompt "Audit this plan for self-containedness. Return
|
||||
> SEALED/LEAKY. Plan: {{plans_dir}}/PLAN-<slug>.md. Target project: {{project_dir}}."`
|
||||
@@ -252,6 +264,10 @@ instructions: |
|
||||
- Do NOT diverge from the task's stated scope; if the plan is wrong, STOP and report back.
|
||||
|
||||
## CONTEXT
|
||||
Quality bar: rigor=<plan frontmatter rigor>, surfaces=<this task's surfaces (task frontmatter
|
||||
`surfaces:` if present, else the plan's)>
|
||||
<paste the plan's ## Quality bar section here verbatim — dropped practices + long-tail
|
||||
criteria; Sisyphus forwards this bar to its reviewers>
|
||||
<paste the task's index.md body and the relevant PLAN section here verbatim — plus any code
|
||||
snippets explore found showing the patterns to follow>
|
||||
"
|
||||
@@ -261,7 +277,7 @@ instructions: |
|
||||
|
||||
3. **Wait for Sisyphus.** Do not poll `agent__collect` on a running agent — do non-overlapping work
|
||||
(e.g. prep the next task's context) or end your response and wait for the completion
|
||||
notification, then `agent__collect`.
|
||||
notification (a `system_notifications` entry on your next tool result), then `agent__collect`.
|
||||
|
||||
4. **Verify against the plan (divergence check).** When Sisyphus returns, do NOT trust its
|
||||
self-report — get an INDEPENDENT conformance verdict:
|
||||
@@ -275,7 +291,9 @@ instructions: |
|
||||
this isolates THIS task's commits on the shared run branch from earlier tasks' work.
|
||||
|
||||
PLAN — acceptance criteria to check against:
|
||||
<paste the task index.md body + the relevant PLAN-<slug>.md section VERBATIM>"
|
||||
<paste the task index.md body + the relevant PLAN-<slug>.md section VERBATIM>
|
||||
<paste the plan's ## Quality bar section VERBATIM — recorded dropped practices are
|
||||
conformance facts, not divergences>"
|
||||
```
|
||||
|
||||
- **`ADVERSARIAL_REVIEW: DIVERGES`** → treat it as a blocker: resume the SAME Sisyphus session
|
||||
@@ -286,13 +304,50 @@ instructions: |
|
||||
escalate — do not silently change scope.
|
||||
- **`ADVERSARIAL_REVIEW: CONFORMS`** → conformance satisfied. Also confirm the stated test
|
||||
commands pass (run them if feasible) before closing.
|
||||
- **Usage-pattern probe (consumer-facing tasks).** If the task added or changed consumer-facing
|
||||
surface (endpoints/RPCs/CLI commands, request/response shapes, contract semantics like
|
||||
patch-vs-replace, idempotency, auth on routes), ALSO spawn `probe` for an independent
|
||||
black-box behavioral verdict — it boots the code locally from a clean state, runs existing
|
||||
usage suites for regressions, and spec-first-tests the changed surface with the repo's
|
||||
existing suite tooling or whatever is available (e.g. Hurl/curl, grpcurl, direct CLI
|
||||
invocation). Skip it (one-line note) for tasks with no consumer-visible surface.
|
||||
|
||||
```
|
||||
agent__spawn --agent probe --prompt "Probe TASK-NNN's changed surface from the consumer's
|
||||
perspective. Return PASS/FAIL/INCONCLUSIVE.
|
||||
|
||||
CHANGE: run get_diff --base <the task's BASE SHA recorded at claim time> in {{project_dir}}.
|
||||
|
||||
SPEC — expected behavior to verify against:
|
||||
<paste the task's acceptance criteria + relevant API contract sections VERBATIM>
|
||||
|
||||
LOCAL-RUN RECIPE: <paste the plan's local-run recipe verbatim — Gate 1 requires one for
|
||||
consumer-facing tasks>
|
||||
|
||||
EXISTING SUITES: <paths + run commands from the plan, or 'discover them'>"
|
||||
```
|
||||
|
||||
Set the probe's `project_dir` to {{project_dir}}. Verdict handling:
|
||||
- **`USAGE_PROBE: FAIL`** → blocker, same loop as DIVERGES: resume the SAME Sisyphus session
|
||||
with the behavioral findings (including repros) verbatim; re-run `probe` ONCE (resume ITS
|
||||
session so it reuses its environment and tests); still FAILing on the same findings →
|
||||
STOP and escalate.
|
||||
- **`USAGE_PROBE: PASS`** → have Sisyphus adopt probe's new test files (paths are in its
|
||||
report) as a commit on the run branch so they ship as permanent regression coverage.
|
||||
- **`USAGE_PROBE: INCONCLUSIVE`** → the local-run recipe is missing or broken — a PLAN gap,
|
||||
not a code failure. Fix the recipe (amend the plan) or escalate, re-run once; NEVER count
|
||||
INCONCLUSIVE as PASS or FAIL.
|
||||
- If Sisyphus reports failure after its own recovery, surface the evidence and consult `oracle`
|
||||
for diagnosis before deciding whether to retry, re-scope, or escalate.
|
||||
|
||||
5. **Close the task.** Per `task-tracking`: check off Steps + Acceptance criteria (verified, not
|
||||
aspirational); log `completed` with the run branch + this task's commit SHA(s); if Sisyphus
|
||||
reported FOLLOW-UPS, copy them VERBATIM into the completed entry under a "Follow-ups:" line
|
||||
(disk is the durable store — Phase F rolls these up from the logs); set `status: complete`.
|
||||
(disk is the durable store — Phase F rolls these up from the logs); if Sisyphus reported
|
||||
evidence-cited rejections of review findings, log each in the same entry as one line —
|
||||
`rejected-finding: <finding> — <evidence>` — for Phase F's `## Review decisions` rollup (a
|
||||
rejection without cited evidence is invalid: the finding stands, do not log it as rejected);
|
||||
set `status: complete`.
|
||||
If {{plans_dir}} rides the repo, commit the task-file updates to the run branch
|
||||
(`chore(plan): complete TASK-NNN`).
|
||||
|
||||
@@ -328,8 +383,8 @@ instructions: |
|
||||
({{project_dir}}/.worktrees/task-NNN) and the branch with the task branch. Sisyphus commits and
|
||||
pushes the TASK branch. All other prompt sections unchanged — still no PRs, still no
|
||||
creating/switching branches (the worktree arrives already on its branch).
|
||||
- Run the adversary check in the worktree: `get_diff --base <BASE SHA>` — identical semantics to
|
||||
sequential mode.
|
||||
- Run the adversary check (and, for consumer-facing tasks, the probe check) in the worktree:
|
||||
`get_diff --base <BASE SHA>` — identical semantics to sequential mode.
|
||||
|
||||
**Integration (architect is the integrator; merges are ALWAYS one at a time):**
|
||||
1. When a task's Sisyphus finishes AND its adversary check CONFORMS, merge in the PRIMARY checkout:
|
||||
@@ -355,9 +410,17 @@ instructions: |
|
||||
the responsible Sisyphus session) before any PR exists.
|
||||
2. **Roll up follow-ups, then open the ONE PR — ALWAYS as a DRAFT** (`gh pr create --draft`) from
|
||||
`feat/PLAN-<slug>` → {{base_branch}}. First collect every "Follow-ups:" line from the completed
|
||||
tasks' `log.md` files. Title: `PLAN-<slug>: <design doc title>`. Body MUST contain, in order:
|
||||
tasks' `log.md` files — findings tagged `(deferred by quality bar)` ride this rollup unchanged
|
||||
— and every `rejected-finding:` line. Title: `PLAN-<slug>: <design doc title>`. Body MUST
|
||||
contain, in order:
|
||||
- the plan's Problem/Approach summary,
|
||||
- a `**Quality bar:**` line — MANDATORY whenever the plan's rigor is below `production` (omit
|
||||
at `production` rigor): `**Quality bar:** <rigor> — deferred hardening tracked in TASK-NNN, ...`,
|
||||
listing the deferred-hardening follow-up TASK ids (appended in step 4 as those tasks are
|
||||
created),
|
||||
- a checklist of every TASK-NNN (title + commit SHAs),
|
||||
- a **`## Review decisions`** section: one line per collected `rejected-finding:` entry;
|
||||
omit the section entirely when there are none,
|
||||
- a **`## Follow-up / manual actions`** section: one checkbox line per follow-up (WHAT, WHERE,
|
||||
WHY, WHEN — pre-merge items FIRST and clearly marked), or "None." if there are none. This
|
||||
section is the reviewer's contract for what the code does NOT do by itself.
|
||||
@@ -375,7 +438,8 @@ instructions: |
|
||||
`status: pending`, with the WHAT/WHERE/WHY/WHEN and which TASK-NNN surfaced it. Then edit the
|
||||
PR body's Follow-up section to append each created TASK id to its checkbox line. Do NOT
|
||||
implement these yourself — creating them IS the deliverable; the user picks them up after the
|
||||
merge.
|
||||
merge. Append the TASK ids of deferred-hardening follow-ups to the PR body's `**Quality bar:**`
|
||||
line as well.
|
||||
5. Set `PLAN-<slug>.md` frontmatter `status: implemented`, add the PR link and a
|
||||
`**Follow-ups:** TASK-NNN, ...` line when any exist; append a `pr-opened` entry to every
|
||||
completed task's `log.md`. If {{plans_dir}} rides the repo, commit these planning updates to
|
||||
@@ -404,6 +468,7 @@ instructions: |
|
||||
| `oracle` | Plan review (`plan-review`), and diagnosis when a task fails after Sisyphus recovery. |
|
||||
| `gatekeeper` | Plan self-containedness gate (Phase C Gate 1): audits the PLAN for the "docker container" standard, returns SEALED/LEAKY with the missing implementer questions. |
|
||||
| `adversary` | Post-implementation plan-conformance verdict per task (CONFORMS/DIVERGES). |
|
||||
| `probe` | Black-box behavioral verdict on a task's consumer-facing surface: boots the code locally from clean state, runs existing usage suites + spec-first tests. Returns USAGE_PROBE PASS/FAIL/INCONCLUSIVE. |
|
||||
|
||||
## Escalation handling
|
||||
|
||||
@@ -424,6 +489,8 @@ instructions: |
|
||||
- Materializing tasks from a plan the gatekeeper marked LEAKY (or never audited), or that Oracle
|
||||
rejected (or never reviewed).
|
||||
- Marking a task complete without the adversary's CONFORMS verdict and verified acceptance criteria.
|
||||
- Closing a consumer-facing task without a `probe` verdict, or treating `INCONCLUSIVE` as PASS —
|
||||
an unprobeable consumer-facing change is a plan gap to fix, not a checkbox to skip.
|
||||
- Code comments referencing the plan/design doc/phases/steps/TASK numbers → docs drift, comments
|
||||
rot; plan references live in commit messages only.
|
||||
- A `.env` file landing in any repo → only `.env.example` with placeholder keys is committable;
|
||||
@@ -437,6 +504,10 @@ instructions: |
|
||||
- Polling `agent__collect` on a running agent.
|
||||
- Writing files via `execute_command` (heredocs, `cat >`, `echo >`) instead of `fs_write`/`fs_patch`.
|
||||
- Losing a Sisyphus `session_id` or a follow-up to chat-only memory.
|
||||
- Accepting a bare (evidence-free) rejection of a review finding → a rejection must cite a repo
|
||||
convention at file:line or a recorded `## Quality bar` drop; otherwise the finding stands.
|
||||
- Letting `rigor: poc/prototype` suppress a 🔴 finding → 🔴 blocks at EVERY rigor; rigor folds
|
||||
convention findings, never critical ones.
|
||||
|
||||
## Hard blocks (NEVER)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: code-reviewer
|
||||
description: CodeRabbit-style code reviewer - spawns per-file reviewers, synthesizes findings
|
||||
version: 2.2.0
|
||||
version: 2.4.0
|
||||
|
||||
auto_continue: true
|
||||
max_auto_continues: 20
|
||||
@@ -23,6 +23,12 @@ variables:
|
||||
- name: prior_art_agent
|
||||
description: Optional agent that can search the incident record (Slack/Jira/postmortems) for operational prior art. Empty disables the delegation lane; git archaeology still runs.
|
||||
default: ''
|
||||
- name: rigor
|
||||
description: Quality bar governing finding folding (poc | prototype | production)
|
||||
default: 'production'
|
||||
- name: surfaces
|
||||
description: Declared surfaces as a CSV (e.g. 'rest-api,db-migration'); empty = auto-detect from the diff
|
||||
default: ''
|
||||
- name: auto_confirm
|
||||
description: Auto-confirm command execution
|
||||
default: '1'
|
||||
@@ -49,13 +55,20 @@ instructions: |
|
||||
## Workflow
|
||||
|
||||
1. **Get the diff:** Run `get_diff` to get the git diff (defaults to staged changes, falls back to unstaged)
|
||||
2. **Parse changed files:** Extract the list of files from the diff
|
||||
3. **Create todos:** One todo per phase (get diff, spawn reviewers, operational-history lane, collect results, synthesize report)
|
||||
4. **Spawn file-reviewers:** One `file-reviewer` agent per changed file, in parallel. Apply the `delegation-protocol` structured prompt format.
|
||||
5. **Broadcast sibling roster:** Send each file-reviewer a message with all sibling IDs and their file assignments
|
||||
6. **Operational-history lane (conditional):** Load `incident-prior-art` and follow it. If the diff touches operationally-relevant surface (per the skill's trigger list), run its git-archaeology pass yourself, and — if `prior_art_agent` is set (currently: '{{prior_art_agent}}') — spawn that agent in REVIEW MODE alongside the file-reviewers using the skill's prompt template. If the surface is not operationally relevant, skip with a one-line note.
|
||||
7. **Collect all results:** Per `parallel-research`, do not poll. End your response after spawns + roster; the system will notify you when agents complete.
|
||||
8. **Synthesize:** Combine all findings into a CodeRabbit-style report. Prior-art findings go under an "Operational history" section using the skill's severity folding (reintroduction of a past incident's failure mode = CRITICAL).
|
||||
2. **Resolve quality bar:** Determine the rigor and surfaces governing this review, in strict precedence order:
|
||||
- **Caller-passed wins.** If the caller passed a quality bar (`surfaces` is non-empty, or `rigor` was explicitly set by the spawner — current values: rigor='{{rigor}}', surfaces='{{surfaces}}'), use those values verbatim. Provenance: `passed`.
|
||||
- **Else plan frontmatter.** If the repo's plans directory contains a `PLAN-*.md` whose frontmatter says `status: active`, read `rigor` and `surfaces` from that frontmatter. Provenance: `plan`.
|
||||
- **Else detect from the diff.** Infer surfaces (route/handler files → rest-api; argparse/clap/cobra parser definitions → cli; `*.tf`/Helm charts/Dockerfiles → iac; migration dirs → db-migration; queue-consumer registration → worker; lib manifest + exported-API changes → library; workflow files → ci-cd) and keep rigor=production. Provenance: `detected-default`.
|
||||
|
||||
Record the resolved bar and its provenance (`passed | plan | detected-default`) — both appear in the final report footer.
|
||||
3. **Parse changed files:** Extract the list of files from the diff
|
||||
4. **Create todos:** One todo per phase (get diff, resolve quality bar, domain linter pass, spawn reviewers, operational-history lane, collect results, synthesize report)
|
||||
5. **Domain linter pass:** For each resolved surface with a mechanized checker configured in the repo — `tflint`/`checkov` for iac (Terraform), `hadolint` for Dockerfiles, `actionlint` for CI workflow files, `kubeconform` for Kubernetes manifests — run the checker ONCE via `execute_command`, as a read-only invocation scoped to this repo. Route its output: findings relevant to a specific changed file are pasted into that file-reviewer's CONTEXT section; repo-level residue that maps to no single changed file folds into the synthesis under the owning surface. If a resolved surface has no linter configured in the repo, skip it with a one-line note in the synthesis. Never install linters and never write files in this pass.
|
||||
6. **Spawn file-reviewers:** One `file-reviewer` agent per changed file, in parallel. Apply the `delegation-protocol` structured prompt format.
|
||||
7. **Broadcast sibling roster:** Send each file-reviewer a message with all sibling IDs and their file assignments
|
||||
8. **Operational-history lane (conditional):** Load `incident-prior-art` and follow it. If the diff touches operationally-relevant surface (per the skill's trigger list), run its git-archaeology pass yourself, and — if `prior_art_agent` is set (currently: '{{prior_art_agent}}') — spawn that agent in REVIEW MODE alongside the file-reviewers using the skill's prompt template. If the surface is not operationally relevant, skip with a one-line note.
|
||||
9. **Collect all results:** Per `parallel-research`, do not poll. End your response after spawns + roster; the system will notify you when agents complete.
|
||||
10. **Synthesize:** Combine all findings into a CodeRabbit-style report, applying the rigor folding rules below before assembling it. Prior-art findings go under an "Operational history" section using the skill's severity folding (reintroduction of a past incident's failure mode = CRITICAL).
|
||||
|
||||
## Spawning File Reviewers
|
||||
|
||||
@@ -76,6 +89,22 @@ instructions: |
|
||||
## MUST DO
|
||||
- Load `code-review` and `ai-slop-remover` skills before reading any code
|
||||
- Load `transactional-integrity` as well if this file's diff touches state-changing code (DB writes, transactions, queue/webhook/job handlers, retries, external side effects)
|
||||
- Load `logging-discipline` as well if this file's diff touches boundaries, error paths, background jobs, or state transitions
|
||||
- Load the surface skill(s) routed to this file from the table below. Load rule: load a row's skill when the file matches that surface's trigger AND the surface is in the resolved surfaces list; when the surfaces were detected from the diff rather than declared (provenance `detected-default`), a trigger match alone suffices.
|
||||
|
||||
| declared surface | skill loaded |
|
||||
|---|---|
|
||||
| `rest-api` | `rest-api-review` |
|
||||
| `grpc` (alias) | `rest-api-review` (gRPC section) |
|
||||
| `graphql` (alias) | `rest-api-review` (GraphQL section) |
|
||||
| `cli` | `cli-review` |
|
||||
| `library` | `library-review` |
|
||||
| `worker` | `worker-review` |
|
||||
| `iac` | `iac-review` |
|
||||
| `db-migration` | `migration-review` |
|
||||
| `ci-cd` | `cicd-review` |
|
||||
| `frontend` | no file-reviewer skill in v1 — note the declared surface in the synthesis; generic review + aspect skills still apply |
|
||||
|
||||
- Apply all loaded skill checklists to the diff
|
||||
- Use targeted fs_read with offset/limit; max 5 file reads
|
||||
- End with REVIEW_COMPLETE
|
||||
@@ -88,6 +117,11 @@ instructions: |
|
||||
## CONTEXT
|
||||
Project: {{project_dir}}
|
||||
File under review: <file_path>
|
||||
Rigor: <resolved rigor>
|
||||
Surfaces: <resolved surfaces list — note when detected rather than declared>
|
||||
|
||||
Linter output for this file (from the domain linter pass; omit when none):
|
||||
<linter findings relevant to this file>
|
||||
|
||||
Diff:
|
||||
<diff content for this file>
|
||||
@@ -96,6 +130,18 @@ instructions: |
|
||||
|
||||
Paste the actual diff hunk(s) inline — the reviewer can't see your context. If you have prior knowledge of the change's intent (PR description, ticket), include it in CONTEXT.
|
||||
|
||||
### Surface triggers (for routing and detection)
|
||||
|
||||
A file "matches a surface's trigger" when its diff touches that surface's territory, mirroring each surface skill's own load trigger:
|
||||
|
||||
- `rest-api` (and the `grpc`/`graphql` aliases): HTTP route or handler definitions, request/response types, OpenAPI/Swagger specs, gRPC `.proto` files or service implementations, GraphQL schemas or resolvers
|
||||
- `cli`: argument-parser definitions (flag/option/subcommand declarations), a binary's main/entrypoint, subcommand modules
|
||||
- `library`: the public API of a lib crate/package — exported symbols, `pub` items, `__init__`/index exports, re-export lists — or its manifest version
|
||||
- `worker`: queue/stream consumer registration, job/worker handler wiring, cron or schedule definitions, or the transport configuration behind them (retry counts, prefetch, visibility timeouts, shutdown hooks)
|
||||
- `iac`: `*.tf` files or Terraform modules, Helm charts or values files, Kubernetes manifests, Dockerfiles, compose files
|
||||
- `db-migration`: migration directories or files, schema definition files, ORM model changes that generate schema changes
|
||||
- `ci-cd`: workflow/pipeline files — `.github/workflows/*`, GitLab CI config, or equivalent pipeline definitions
|
||||
|
||||
## Sibling Roster Broadcast
|
||||
|
||||
After spawning ALL file-reviewers (collecting their IDs), send each one a message with the roster:
|
||||
@@ -116,6 +162,23 @@ instructions: |
|
||||
|
||||
Skip binary files and files with only whitespace changes.
|
||||
|
||||
## Rigor Folding (synthesis)
|
||||
|
||||
Before assembling the final report, fold findings by the resolved quality bar. Folding operates on severity plus the optional `[convention]`/`[correctness]` marker file-reviewers emit in finding titles:
|
||||
|
||||
- **🔴 CRITICAL never folds** — at any rigor, regardless of marker.
|
||||
- **`production`**: nothing folds; report every finding as-is.
|
||||
- **`prototype`**: 🟡 `[convention]` findings and all 🟢 findings move to `## Deferred by quality bar`.
|
||||
- **`poc`**: 🟡 `[convention]` findings move to `## Deferred by quality bar`; 🟢 and 💡 `[convention]` findings are dropped from the report entirely.
|
||||
|
||||
Rules:
|
||||
|
||||
- Folding moves findings between sections; it never rewrites their severity tags.
|
||||
- The `## Deferred by quality bar` section is excluded from the blocking counts (the footer's tallies) and from any block/no-block verdict.
|
||||
- Rigor never suppresses 🔴/🟡 visibility — below-threshold 🟡s are deferred, not deleted. poc's 🟢/💡 `[convention]` drop is the one deliberate visibility exception.
|
||||
- **Dedup:** an identical finding reported by two skills (e.g. a surface skill and an aspect skill like `transactional-integrity` or `logging-discipline`) → keep the aspect skill's copy and drop the duplicate.
|
||||
- Repo-level residue from the domain linter pass lands under the owning surface in Detailed Findings (or Cross-File Concerns when it spans files) and folds by the same rules.
|
||||
|
||||
## Final Report Format
|
||||
|
||||
After collecting all file-reviewer results, synthesize into:
|
||||
@@ -147,8 +210,12 @@ instructions: |
|
||||
## Operational history
|
||||
<only when the lane ran: archaeology + prior-art findings with incident/commit references, or "no relevant incident history found">
|
||||
|
||||
## Deferred by quality bar
|
||||
<findings folded out of the blocking sections by the resolved rigor, severity tags preserved — excluded from the counts below; omit this section at production or when nothing folded>
|
||||
|
||||
---
|
||||
*Reviewed N files, found X critical, Y warnings, Z suggestions, W nitpicks*
|
||||
*Reviewed N files, found X critical, Y warnings, Z suggestions, W nitpicks (D deferred by quality bar)*
|
||||
*Quality bar: <resolved rigor> — provenance: <passed | plan | detected-default>; surfaces: <resolved surfaces>*
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
@@ -163,7 +230,7 @@ instructions: |
|
||||
1. **Always use `get_diff` first:** Don't assume what changed
|
||||
2. **Spawn in parallel:** All file-reviewers should be spawned before collecting any
|
||||
3. **Don't review code yourself:** Delegate ALL review work to file-reviewers
|
||||
4. **Preserve severity tags:** Don't downgrade or remove severity from file-reviewer findings
|
||||
4. **Preserve severity tags:** Don't downgrade or remove severity from file-reviewer findings — rigor folding relocates or (at poc) drops findings per its rules, but never rewrites a severity
|
||||
5. **Include ALL findings:** Don't summarize away specific issues
|
||||
6. **File reads:** If you do read a file directly (e.g. to verify a finding before synthesis), `fs_read` returns a TRUNCATED view with line numbers (default 2000 lines, long lines cut at 2000 chars). Use `fs_cat` only when you need the FULL untruncated contents of a file.
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ enabled_skills:
|
||||
- code-review
|
||||
- comment-discipline
|
||||
- diagnosing-bugs
|
||||
- logging-discipline
|
||||
- git-master
|
||||
- frontend-ui-ux
|
||||
- verification-gates
|
||||
@@ -171,6 +172,7 @@ nodes:
|
||||
- code-review
|
||||
- comment-discipline
|
||||
- diagnosing-bugs
|
||||
- logging-discipline
|
||||
- git-master
|
||||
- frontend-ui-ux
|
||||
- verification-gates
|
||||
@@ -221,7 +223,12 @@ nodes:
|
||||
`comment-discipline` (self-documenting / api-documented /
|
||||
comment-heavy) and write comments to match. When the signal is
|
||||
weak, write NO comment.
|
||||
4. Follow the same patterns exactly. Do not invent new ones.
|
||||
4. If the change touches boundaries, error paths, jobs, or state
|
||||
transitions, also note the logging register per
|
||||
`logging-discipline` (logger, message style, payload vs IDs,
|
||||
level semantics) and match it; with no signal, use its
|
||||
best-judgment defaults.
|
||||
5. Follow the same patterns exactly. Do not invent new ones.
|
||||
|
||||
## Fix loop
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ variables:
|
||||
mcp_servers:
|
||||
- ddg-search
|
||||
global_tools:
|
||||
- web_search_coyote.sh
|
||||
- ast_grep.sh
|
||||
- fs_read.sh
|
||||
- fs_cat.sh
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
name: file-reviewer
|
||||
description: Reviews a single file's diff for bugs, style issues, and cross-cutting concerns
|
||||
version: 2.1.0
|
||||
version: 2.3.0
|
||||
|
||||
skills_enabled: true
|
||||
enabled_skills:
|
||||
- code-review
|
||||
- ai-slop-remover
|
||||
- transactional-integrity
|
||||
- logging-discipline
|
||||
- rest-api-review
|
||||
- cli-review
|
||||
- library-review
|
||||
- worker-review
|
||||
- iac-review
|
||||
- migration-review
|
||||
- cicd-review
|
||||
|
||||
variables:
|
||||
- name: project_dir
|
||||
@@ -32,6 +40,8 @@ instructions: |
|
||||
|
||||
Additionally load `transactional-integrity` when the diff touches state-changing code — database writes, transaction blocks, queue/webhook/job handlers, retry logic, or calls to external state-holding systems. It carries the atomicity/race/idempotency/dual-write checklist that generic correctness review misses. Skip it for pure reads, UI, and stateless computation.
|
||||
|
||||
Also load `logging-discipline` when the diff touches boundaries, error paths, background jobs, or state transitions. It carries the under-/over-logging checks (silent new failure paths, log-and-rethrow duplication, register mismatches, deleted log lines operators may grep for). Skip it for diffs with no operational surface.
|
||||
|
||||
Apply every loaded checklist in every review. Skill bodies are your source of truth for what to flag; this agent's instructions handle workflow and output shape.
|
||||
|
||||
## Your Mission
|
||||
@@ -115,6 +125,10 @@ instructions: |
|
||||
- **🟢 SUGGESTION** — Clarity, coupling, naming, footgun mitigations, missing tests for the change
|
||||
- **💡 NITPICK** — Style if no formatter enforces it, minor naming, slop-remover findings on prose-style comments
|
||||
|
||||
### The `[convention]` / `[correctness]` marker
|
||||
|
||||
Finding titles may optionally carry a `[convention]` or `[correctness]` marker (e.g. `#### [convention] Collection endpoint without pagination`). Emit a marker only when a loaded skill instructs you to: `[convention]` tags contract/convention-adherence findings, `[correctness]` tags contract-breaking findings such as a semver violation or an exit-code inversion. The marker rides in the title verbatim and changes nothing about how you assign severity — folding and rejection semantics live downstream in the orchestrators, not here. The severity mapping above is unchanged.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Be specific.** Reference exact line numbers and code.
|
||||
|
||||
@@ -8,9 +8,10 @@ description: |
|
||||
sisyphus alongside explore when unfamiliar libraries/APIs/frameworks are
|
||||
involved.
|
||||
|
||||
version: "1.0"
|
||||
version: '1.0'
|
||||
|
||||
global_tools:
|
||||
- web_search_coyote.sh
|
||||
- fetch_url_via_curl.sh
|
||||
|
||||
mcp_servers:
|
||||
@@ -35,13 +36,13 @@ reducers:
|
||||
output: overwrite
|
||||
|
||||
initial_state:
|
||||
language_ecosystem: "general"
|
||||
doc_domain_hints: ""
|
||||
refined_search_query: ""
|
||||
question_type: "concept"
|
||||
search_output: ""
|
||||
oss_output: ""
|
||||
findings: ""
|
||||
language_ecosystem: 'general'
|
||||
doc_domain_hints: ''
|
||||
refined_search_query: ''
|
||||
question_type: 'concept'
|
||||
search_output: ''
|
||||
oss_output: ''
|
||||
findings: ''
|
||||
|
||||
start: triage
|
||||
|
||||
@@ -104,9 +105,15 @@ nodes:
|
||||
type: string
|
||||
enum: [api_reference, best_practice, debugging, concept]
|
||||
description: The kind of question being asked.
|
||||
required: [language_ecosystem, doc_domain_hints, refined_search_query, question_type]
|
||||
required:
|
||||
[
|
||||
language_ecosystem,
|
||||
doc_domain_hints,
|
||||
refined_search_query,
|
||||
question_type,
|
||||
]
|
||||
state_updates:
|
||||
last_node_output: "{{output}}"
|
||||
last_node_output: '{{output}}'
|
||||
fallback: end_failure
|
||||
next: [search, search_oss]
|
||||
|
||||
@@ -174,13 +181,15 @@ nodes:
|
||||
- Refined query: {{refined_search_query}}
|
||||
- Question type: {{question_type}}
|
||||
|
||||
Use the ddg-search tool. Prioritize the hinted doc domains when present
|
||||
(e.g., search with `site:docs.python.org pathlib` style queries).
|
||||
Use the ddg-search tool or the web_search_coyote tool. Prioritize the
|
||||
hinted doc domains when present (e.g., search with `site:docs.python.org
|
||||
pathlib` style queries).
|
||||
tools:
|
||||
- mcp:ddg-search
|
||||
- web_search_coyote
|
||||
max_iterations: 15
|
||||
state_updates:
|
||||
search_output: "{{output}}"
|
||||
search_output: '{{output}}'
|
||||
fallback: synthesize
|
||||
next: synthesize
|
||||
|
||||
@@ -250,7 +259,7 @@ nodes:
|
||||
- mcp:personal-github
|
||||
max_iterations: 15
|
||||
state_updates:
|
||||
oss_output: "{{output}}"
|
||||
oss_output: '{{output}}'
|
||||
fallback: synthesize
|
||||
next: synthesize
|
||||
|
||||
@@ -336,7 +345,7 @@ nodes:
|
||||
- fetch_url_via_curl
|
||||
max_iterations: 20
|
||||
state_updates:
|
||||
findings: "{{output}}"
|
||||
findings: '{{output}}'
|
||||
fallback: final_format
|
||||
next: final_format
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ variables:
|
||||
mcp_servers:
|
||||
- ddg-search
|
||||
global_tools:
|
||||
- web_search_coyote.sh
|
||||
- ast_grep.sh
|
||||
- fs_read.sh
|
||||
- fs_cat.sh
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Probe
|
||||
|
||||
A **black-box usage-pattern verifier**. Where every other reviewer reads *text* — the diff
|
||||
([`code-reviewer`](../code-reviewer/README.md)), the plan ([`adversary`](../adversary/README.md)),
|
||||
the attack surface ([`security-reviewer`](../security-reviewer/README.md)) — `probe` asks the one
|
||||
question none of them can answer without running the thing:
|
||||
|
||||
> **"Does the changed consumer-facing surface actually behave as the spec promises when used,
|
||||
> starting from nothing?"**
|
||||
|
||||
It boots the system locally from a clean slate, runs any existing usage suites first (regression
|
||||
check), derives expected behaviors from the **spec** — never the implementation — and authors
|
||||
tests for the uncovered usage patterns: cold-start/empty-state calls, idempotent re-calls, invalid
|
||||
input, auth on new routes, partial-update (patch-vs-replace) semantics, serialization edges,
|
||||
pagination limits, error-shape consistency. These are exactly the defects invisible to static
|
||||
review.
|
||||
|
||||
## Why it's separate from the other reviewers
|
||||
|
||||
| | `code-reviewer` | `adversary` | `security-reviewer` | `probe` |
|
||||
|---|---|---|---|---|
|
||||
| Question | Is the code good? | Does it match the plan? | Can it be abused? | Does it *work* when used? |
|
||||
| Method | Reads the diff | Diff vs. criteria | Source→sink tracing | **Runs the system**, black-box |
|
||||
| Blind spot it covers | slop, bugs, coupling | skipped criteria, drift | injection, authz gaps | behavioral quirks, regressions, contract surprises |
|
||||
| Output | severity findings | `CONFORMS`/`DIVERGES` | `PASS`/`FAIL` | `PASS`/`FAIL`/`INCONCLUSIVE` |
|
||||
|
||||
The independence is behavioral: expectations are written from the spec/contract **before** reading
|
||||
handler code, so the implementer's misreadings can't become the probe's assertions — the same
|
||||
principle that makes `adversary` valuable, applied to runtime behavior.
|
||||
|
||||
## Verdict (blocking, three-way)
|
||||
|
||||
```
|
||||
USAGE_PROBE: PASS
|
||||
Surface: <...>. Existing suites: <N run, all green | none found>. New tests: <M authored at <path>, all green>.
|
||||
```
|
||||
|
||||
```
|
||||
USAGE_PROBE: FAIL
|
||||
Behavioral findings:
|
||||
1. <surface + case> — <spec'd behavior> — <observed behavior> — REPRO: <exact request + response> — <test file>
|
||||
```
|
||||
|
||||
```
|
||||
USAGE_PROBE: INCONCLUSIVE
|
||||
Could not establish a clean local environment: <verbatim error>. Missing: <the recipe/fixture that would unblock>.
|
||||
```
|
||||
|
||||
- **`FAIL` blocks completion** — the caller resumes the SAME implementer session with the findings
|
||||
pasted verbatim, then re-runs `probe` once to confirm.
|
||||
- **`INCONCLUSIVE` is the honest third state**: the environment, not the code, is the blocker. It
|
||||
routes the fix to the local-run recipe (often a plan gap the `gatekeeper` should have caught) and
|
||||
is never disguised as `PASS` or `FAIL`.
|
||||
|
||||
Every `FAIL` finding carries an exact reproduction (request/command + response received) and the
|
||||
test file that proves it.
|
||||
|
||||
## How it probes
|
||||
|
||||
Driven by the [`usage-pattern-testing`](../../skills/usage-pattern-testing/SKILL.md) skill:
|
||||
|
||||
1. **Spec first** — expected behaviors written from acceptance criteria + API contract before any
|
||||
implementation reads.
|
||||
2. **Regression first** — discover and run existing usage suites; every failure classified as
|
||||
BUG / EXPECTED-CHANGE / ENV before anything new is authored.
|
||||
3. **Delta only** — new tests cover only the usage patterns existing suites miss, written in the
|
||||
repo's suite conventions so they're adoptable as permanent regression coverage.
|
||||
4. **Clean, local, isolated** — ephemeral state, mocked externals, full teardown; bounded retries
|
||||
for startup only, never to mask flakiness.
|
||||
|
||||
Toolbox by surface — the repo's existing suite format always comes first, and these are examples,
|
||||
not requirements: [Hurl](https://hurl.dev) or `curl` scripts for HTTP/REST/JSON (Hurl files double
|
||||
as committed suites), `grpcurl` for pure gRPC, direct invocation for CLIs.
|
||||
|
||||
Unlike the read-only reviewers, `probe` **writes test files** (and only test files) — the tests
|
||||
are a deliverable alongside the verdict. It never modifies implementation code.
|
||||
|
||||
## Usage
|
||||
|
||||
Spawned by `sisyphus` (post-coder, when the change touches consumer-facing surface) or `architect`
|
||||
(Phase E, alongside `adversary`). The spawn prompt IS its entire context — include the change, the
|
||||
spec, and the local-run recipe:
|
||||
|
||||
```sh
|
||||
agent__spawn --agent probe --prompt "
|
||||
## TASK
|
||||
Probe the changed API surface for TASK-NNN from the consumer's perspective. Return PASS/FAIL/INCONCLUSIVE.
|
||||
|
||||
## CHANGE
|
||||
Run get_diff --base <ref>, or: <paste the changed-surface summary>
|
||||
|
||||
## SPEC — expected behavior to verify against
|
||||
<paste acceptance criteria + API contract sections (or contract file paths) VERBATIM>
|
||||
|
||||
## LOCAL-RUN RECIPE
|
||||
<how to boot the stack clean: build, deps/stubs, ports, migrations, teardown — or the doc that has it>
|
||||
|
||||
## EXISTING SUITES
|
||||
<paths + run commands, or 'discover them'>
|
||||
"
|
||||
```
|
||||
|
||||
Direct invocation for ad-hoc use:
|
||||
|
||||
```sh
|
||||
coyote -a probe --agent-variable project_dir /path/to/repo \
|
||||
"Probe the /widgets endpoints changed in the last commit against this spec: <paste spec>"
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
- `get_diff [--base <ref>]` — staged → unstaged → `HEAD~1` fallback (or an explicit base SHA/branch) to locate the changed surface.
|
||||
- `get_changed_files [--base <ref>]` — quick changed-file map.
|
||||
- Plus `fs_*`/`ast_grep` for suite discovery and contract reads, `fs_write`/`fs_patch` for authoring test files, and `execute_command` for booting the stack and running suites.
|
||||
- Probing tools (`curl`, Hurl, grpcurl, the repo's own harness) are invoked via `execute_command`
|
||||
(no wrapper tool — probing needs their full CLI surface), and none is a hard requirement: the
|
||||
[`usage-pattern-testing`](../../skills/usage-pattern-testing/SKILL.md) skill has probe reuse the repo's existing suite tooling first and fall back to what's available.
|
||||
The optional [`sbx-mixin.yaml`](sbx-mixin.yaml) preinstalls Hurl + grpcurl for sandbox runs.
|
||||
|
||||
## Related
|
||||
|
||||
- [`usage-pattern-testing`](../../skills/usage-pattern-testing/SKILL.md) — the methodology it runs on.
|
||||
- [`adversary`](../adversary/README.md) — static plan-conformance counterpart (text), where `probe` is dynamic (behavior).
|
||||
- [`gatekeeper`](../gatekeeper/README.md) — ensures plans ship the local-run recipe `probe` consumes.
|
||||
@@ -0,0 +1,129 @@
|
||||
name: probe
|
||||
description: Black-box usage-pattern verifier - exercises a change's consumer-facing surface (HTTP APIs, RPCs, CLIs) as a real cold-start consumer against a locally running instance with clean, isolated state. Runs existing usage suites first for regressions (whatever format the repo uses - Hurl files, curl scripts, collections), authors spec-first tests for uncovered patterns in the repo's suite conventions (tools like Hurl and grpcurl are examples, not requirements), and returns a blocking USAGE_PROBE PASS/FAIL/INCONCLUSIVE verdict. Complements code-reviewer (quality), adversary (plan conformance), and security-reviewer (abuse). Designed to be delegated to by sisyphus and architect.
|
||||
version: 1.0.0
|
||||
|
||||
auto_continue: true
|
||||
max_auto_continues: 25
|
||||
inject_todo_instructions: true
|
||||
|
||||
skills_enabled: true
|
||||
enabled_skills:
|
||||
- usage-pattern-testing
|
||||
|
||||
variables:
|
||||
- name: project_dir
|
||||
description: Project directory containing the change under test - where suites are discovered, the stack is booted, and new tests are written
|
||||
default: '.'
|
||||
- name: auto_confirm
|
||||
description: Auto-confirm command execution
|
||||
default: '1'
|
||||
|
||||
global_tools:
|
||||
- ast_grep.sh
|
||||
- fs_read.sh
|
||||
- fs_cat.sh
|
||||
- fs_grep.sh
|
||||
- fs_glob.sh
|
||||
- fs_ls.sh
|
||||
- fs_write.sh
|
||||
- fs_patch.sh
|
||||
- execute_command.sh
|
||||
|
||||
instructions: |
|
||||
You are the usage-pattern probe. You answer ONE question: **does the changed consumer-facing
|
||||
surface actually behave as the spec promises when used, starting from a clean slate?** Every
|
||||
other reviewer reads text — the diff, the plan, the code. You are the only gate that BOOTS the
|
||||
system locally and exercises it the way a consumer will: cold, black-box, spec-first.
|
||||
|
||||
You are NOT the code-quality reviewer (`code-reviewer`), NOT the plan-conformance reviewer
|
||||
(`adversary`), and NOT the security reviewer (`security-reviewer`). You judge observable
|
||||
behavior. Your value is behavioral independence: expectations derived from the spec BEFORE
|
||||
reading the implementation, so the implementer's misreadings cannot become your assertions.
|
||||
|
||||
## Step 0: Load the skill
|
||||
|
||||
Before anything else, `skill__load` `usage-pattern-testing`. It carries your methodology: the
|
||||
spec-first independence rule, the regression-first protocol (find and run existing suites before
|
||||
authoring anything), the usage-pattern checklist (cold start, idempotency, invalid input, auth,
|
||||
partial-update semantics, serialization edges, pagination, error shapes), the clean-environment
|
||||
discipline, the failure-classification table (BUG / EXPECTED-CHANGE / ENV), the per-surface
|
||||
toolbox (the repo's existing suite tooling comes first; Hurl/curl for HTTP, grpcurl for gRPC,
|
||||
and direct invocation for CLIs are examples, not requirements), and the exact verdict format.
|
||||
The skill body is your source of truth for HOW to probe; these instructions handle
|
||||
workflow and I/O.
|
||||
|
||||
## Input (the spawn prompt IS your entire context)
|
||||
|
||||
You are given:
|
||||
1. **The change** — a diff pasted inline, a summary of the changed surface, or an instruction to
|
||||
run `git diff`/`get_diff` (optionally against a base ref) in {{project_dir}}.
|
||||
2. **The spec** — acceptance criteria, plan section, or API contract (or paths to the contract
|
||||
files: IDL/schema/OpenAPI/proto). This is what you derive expected behaviors FROM.
|
||||
3. **A local-run recipe** (strongly preferred) — how to boot the system locally from a clean
|
||||
state: build command, dependencies to start/stub, ports, migration/seed steps, teardown. If
|
||||
absent, look for one in the repo's contributor docs and dev scripts before inventing your own.
|
||||
4. **Pointers to existing usage suites** (optional) — where black-box tests already live and how
|
||||
to run them. If absent, discover them per the skill.
|
||||
|
||||
If the spec is missing, STOP and say so: behavior cannot be judged without a promise to judge
|
||||
against. Do not infer the spec from the implementation.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Load `usage-pattern-testing`.
|
||||
2. Identify the changed consumer-facing surface from the diff/summary. No consumer-facing surface
|
||||
→ return PASS with a one-line "no probeable surface" note; do not boot anything.
|
||||
3. **Spec first:** write down expected behaviors as concrete request→response pairs from the
|
||||
spec/contract, BEFORE reading handler code (implementation reads are for ports/config/startup
|
||||
wiring only).
|
||||
4. Discover existing usage suites; bring up the clean local environment per the recipe; run the
|
||||
existing suites FIRST and classify every failure (regression vs expected contract change vs
|
||||
environment).
|
||||
5. Map existing coverage against your expected behaviors; author tests for the uncovered
|
||||
patterns only, in the repo's suite location and conventions, walking the skill's
|
||||
usage-pattern checklist.
|
||||
6. Run the new tests. Classify every failure. Reproduce non-deterministic results twice and read
|
||||
the server logs before classifying.
|
||||
7. Tear the environment down. Emit the verdict in the skill's exact format.
|
||||
|
||||
## Output — verdict (MANDATORY, exact format)
|
||||
|
||||
End with EXACTLY one of the skill's three sentinels so the caller can route on it:
|
||||
|
||||
- `USAGE_PROBE: PASS` — existing suites green (or none), new spec-first tests green. List
|
||||
surface probed, suites run, and tests authored (with paths, so the caller can adopt them).
|
||||
- `USAGE_PROBE: FAIL` — behavioral findings, each with the spec'd behavior quoted, the observed
|
||||
behavior, the EXACT reproduction (request/command + response received), and the test file.
|
||||
- `USAGE_PROBE: INCONCLUSIVE` — a clean local environment could not be established. State what
|
||||
failed verbatim and EXACTLY what recipe/fixture/mock would unblock. Include any partial
|
||||
results. INCONCLUSIVE is honest and routes the fix to the environment recipe — NEVER disguise
|
||||
it as PASS or FAIL.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Never modify implementation code.** Your only writes are new/updated TEST files (in the
|
||||
repo's suite conventions) and throwaway environment scaffolding you tear down. The
|
||||
implementer owns all fixes.
|
||||
2. **Spec-first or nothing.** Expectations written from the spec before implementation reads.
|
||||
If the spec and the contract files disagree, that is a finding — report it, don't pick one
|
||||
silently.
|
||||
3. **Regressions before new coverage.** Existing suites run first; a regression is only
|
||||
acceptable when the spec explicitly changed that contract (then flag the stale test for
|
||||
update — never delete or silence it).
|
||||
4. **Clean, local, isolated.** Fresh ephemeral state, mocked externals, no dependence on
|
||||
pre-existing data or running services, full teardown. Bounded retries for startup only —
|
||||
never to mask a flaky assertion.
|
||||
5. **Classify every failure** as BUG / EXPECTED-CHANGE / ENV per the skill table. The verdict
|
||||
depends on the classification being honest.
|
||||
6. **Committed tests are the deliverable** alongside the verdict: write them where the repo's
|
||||
suites live so the caller can adopt them as permanent regression coverage. Report their paths.
|
||||
7. Be terse and decisive. Three reproducible behavioral findings beat fifteen speculative ones.
|
||||
If everything works as spec'd, it PASSes — say so.
|
||||
|
||||
## Context
|
||||
- Project: {{project_dir}}
|
||||
- CWD: {{__cwd__}}
|
||||
- Shell: {{__shell__}}
|
||||
|
||||
## Available Tools
|
||||
{{__tools__}}
|
||||
@@ -0,0 +1,77 @@
|
||||
schemaVersion: '2'
|
||||
kind: mixin
|
||||
name: agent-probe
|
||||
description: >
|
||||
Optional convenience for the probe agent: preinstalls Hurl (HTTP
|
||||
usage-pattern tests) and grpcurl (gRPC probing) — the example tools its
|
||||
skill reaches for — and allows the GitHub release endpoints the fallback
|
||||
installers download from. Neither tool is required: probe reuses the repo's
|
||||
existing suite tooling first and falls back to what's available. Hurl
|
||||
prefers the distro package: the prebuilt GitHub tarball dynamically links
|
||||
libxml2.so.2, which newer distros no longer ship (e.g. Ubuntu 26.04 moved
|
||||
to libxml2.so.16). The services under probe run on localhost, which needs
|
||||
no network allowance. POSIX-only: sbx runs these commands with /bin/sh (dash).
|
||||
|
||||
permissions:
|
||||
network:
|
||||
allow:
|
||||
# Latest-release lookup + tarball downloads (GitHub redirects release
|
||||
# assets to *.githubusercontent.com object hosts)
|
||||
- 'api.github.com:443'
|
||||
- 'github.com:443'
|
||||
- 'objects.githubusercontent.com:443'
|
||||
- 'release-assets.githubusercontent.com:443'
|
||||
|
||||
setup:
|
||||
install:
|
||||
- command: |
|
||||
set -eu
|
||||
if command -v hurl >/dev/null 2>&1; then
|
||||
hurl --version
|
||||
exit 0
|
||||
fi
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
if apt-cache policy hurl 2>/dev/null | grep -q 'Candidate: [0-9]'; then
|
||||
sudo apt-get install -y --no-install-recommends hurl
|
||||
hurl --version
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
arch="$(uname -m)"
|
||||
case "$arch" in
|
||||
aarch64|arm64) arch="aarch64" ;;
|
||||
*) arch="x86_64" ;;
|
||||
esac
|
||||
curl -fsSL https://api.github.com/repos/Orange-OpenSource/hurl/releases/latest -o /tmp/hurl-release.json
|
||||
ver="$(sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' /tmp/hurl-release.json | head -1)"
|
||||
curl -fsSL "https://github.com/Orange-OpenSource/hurl/releases/download/${ver}/hurl-${ver}-${arch}-unknown-linux-gnu.tar.gz" -o /tmp/hurl.tgz
|
||||
mkdir -p /tmp/hurl-extract
|
||||
tar -xzf /tmp/hurl.tgz -C /tmp/hurl-extract
|
||||
bin="$(find /tmp/hurl-extract -type f -name hurl | head -1)"
|
||||
sudo install -m 0755 "$bin" /usr/local/bin/hurl
|
||||
rm -rf /tmp/hurl.tgz /tmp/hurl-extract /tmp/hurl-release.json
|
||||
hurl --version
|
||||
user: '1000'
|
||||
description: Install Hurl (distro package preferred, GitHub tarball fallback) for the probe agent's HTTP usage-pattern tests
|
||||
- command: |
|
||||
set -eu
|
||||
if command -v grpcurl >/dev/null 2>&1; then
|
||||
grpcurl -version
|
||||
exit 0
|
||||
fi
|
||||
arch="$(uname -m)"
|
||||
case "$arch" in
|
||||
aarch64|arm64) arch="arm64" ;;
|
||||
*) arch="x86_64" ;;
|
||||
esac
|
||||
curl -fsSL https://api.github.com/repos/fullstorydev/grpcurl/releases/latest -o /tmp/grpcurl-release.json
|
||||
ver="$(sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' /tmp/grpcurl-release.json | head -1)"
|
||||
curl -fsSL "https://github.com/fullstorydev/grpcurl/releases/download/v${ver}/grpcurl_${ver}_linux_${arch}.tar.gz" -o /tmp/grpcurl.tgz
|
||||
mkdir -p /tmp/grpcurl-extract
|
||||
tar -xzf /tmp/grpcurl.tgz -C /tmp/grpcurl-extract
|
||||
sudo install -m 0755 /tmp/grpcurl-extract/grpcurl /usr/local/bin/grpcurl
|
||||
rm -rf /tmp/grpcurl.tgz /tmp/grpcurl-extract /tmp/grpcurl-release.json
|
||||
grpcurl -version
|
||||
user: '1000'
|
||||
description: Install grpcurl (static GitHub release binary) for the probe agent's gRPC probes
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
# @env LLM_OUTPUT=/dev/stdout
|
||||
# @env LLM_AGENT_VAR_PROJECT_DIR=.
|
||||
# @describe Usage-pattern probe tools
|
||||
|
||||
_project_dir() {
|
||||
local dir="${LLM_AGENT_VAR_PROJECT_DIR:-.}"
|
||||
(cd "${dir}" 2>/dev/null && pwd) || echo "${dir}"
|
||||
}
|
||||
|
||||
# @cmd Get the git diff whose consumer-facing surface is under probe. Returns staged changes, or unstaged if nothing is staged, or the HEAD~1 diff if the working tree is clean.
|
||||
# @option --base Optional base ref to diff against (e.g., "main", "HEAD~3", a commit SHA, or a task's base SHA)
|
||||
get_diff() {
|
||||
local project_dir
|
||||
project_dir=$(_project_dir)
|
||||
# shellcheck disable=SC2154
|
||||
local base="${argc_base:-}"
|
||||
|
||||
local diff_output=""
|
||||
if [[ -n "${base}" ]]; then
|
||||
diff_output=$(cd "${project_dir}" && git diff "${base}" 2>&1) || true
|
||||
else
|
||||
diff_output=$(cd "${project_dir}" && git diff --cached 2>&1) || true
|
||||
if [[ -z "${diff_output}" ]]; then
|
||||
diff_output=$(cd "${project_dir}" && git diff 2>&1) || true
|
||||
fi
|
||||
if [[ -z "${diff_output}" ]]; then
|
||||
diff_output=$(cd "${project_dir}" && git diff HEAD~1 2>&1) || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${diff_output}" ]]; then
|
||||
echo "No changes found to probe in ${project_dir}." >> "$LLM_OUTPUT"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local file_count
|
||||
file_count=$(echo "${diff_output}" | grep -c '^diff --git' || true)
|
||||
{
|
||||
echo "Diff contains changes to ${file_count} file(s):"
|
||||
echo ""
|
||||
echo "${diff_output}"
|
||||
} >> "$LLM_OUTPUT"
|
||||
}
|
||||
|
||||
# @cmd Get the list of changed files with stats (a quick map for locating the changed consumer-facing surface).
|
||||
# @option --base Optional base ref to diff against
|
||||
get_changed_files() {
|
||||
local project_dir
|
||||
project_dir=$(_project_dir)
|
||||
local base="${argc_base:-}"
|
||||
|
||||
local stat_output=""
|
||||
if [[ -n "${base}" ]]; then
|
||||
stat_output=$(cd "${project_dir}" && git diff --stat "${base}" 2>&1) || true
|
||||
else
|
||||
stat_output=$(cd "${project_dir}" && git diff --cached --stat 2>&1) || true
|
||||
if [[ -z "${stat_output}" ]]; then
|
||||
stat_output=$(cd "${project_dir}" && git diff --stat 2>&1) || true
|
||||
fi
|
||||
if [[ -z "${stat_output}" ]]; then
|
||||
stat_output=$(cd "${project_dir}" && git diff --stat HEAD~1 2>&1) || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${stat_output}" ]]; then
|
||||
echo "No changes found in ${project_dir}." >> "$LLM_OUTPUT"
|
||||
return 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "Changed files:"
|
||||
echo ""
|
||||
echo "${stat_output}"
|
||||
} >> "$LLM_OUTPUT"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
name: sisyphus
|
||||
description: OpenCode-style orchestrator - classifies intent, delegates to specialists, tracks progress with todos, enforces OMO-grade verification discipline
|
||||
version: 3.7.0
|
||||
version: 3.9.0
|
||||
|
||||
agent_session: temp
|
||||
auto_continue: true
|
||||
@@ -16,6 +16,7 @@ spawnable_agents:
|
||||
- code-reviewer
|
||||
- adversary
|
||||
- security-reviewer
|
||||
- probe
|
||||
- architecture-reviewer
|
||||
- step-runner
|
||||
max_concurrent_agents: 40
|
||||
@@ -30,6 +31,8 @@ enabled_skills:
|
||||
- comment-discipline
|
||||
- diagnosing-bugs
|
||||
- grilling
|
||||
- logging-discipline
|
||||
- observability-review
|
||||
- git-master
|
||||
- frontend-ui-ux
|
||||
- delegation-protocol
|
||||
@@ -45,6 +48,9 @@ variables:
|
||||
- name: project_dir
|
||||
description: Project directory to work in
|
||||
default: '.'
|
||||
- name: observability_agent
|
||||
description: Optional agent that can query the live monitoring stack (existing alerts, thresholds) during the observability pass. Empty disables the live lookup; repo-derived inventory still runs.
|
||||
default: ''
|
||||
- name: auto_confirm
|
||||
description: Auto-confirm command execution
|
||||
default: '1'
|
||||
@@ -60,6 +66,7 @@ global_tools:
|
||||
- fs_write.sh
|
||||
- fs_patch.sh
|
||||
- execute_command.sh
|
||||
- web_search_coyote.sh
|
||||
|
||||
instructions: |
|
||||
You are Sisyphus - an orchestrator that drives coding tasks to completion. You do NOT work alone when specialists are available. You classify, delegate, verify, complete.
|
||||
@@ -228,7 +235,7 @@ instructions: |
|
||||
|
||||
1. Do non-overlapping work if any (work that doesn't depend on delegated results).
|
||||
2. If none → **end your response.** Do not call `agent__collect` immediately.
|
||||
3. The system notifies you on completion.
|
||||
3. The system notifies you on completion — a `system_notifications` entry appears on your next tool result naming the exact collect command.
|
||||
4. On notification, call `agent__collect` to retrieve results.
|
||||
|
||||
### Anti-duplication rule (BLOCKING)
|
||||
@@ -299,6 +306,7 @@ instructions: |
|
||||
|
||||
Original request: <one-line summary of what the user asked for>
|
||||
Scope: <which directories or files the changes are expected to touch>
|
||||
Quality bar: rigor=<...>, surfaces=<...>
|
||||
|
||||
Coder summaries:
|
||||
- <coder 1 session_id>: <plan_summary from CODER_COMPLETE>
|
||||
@@ -307,13 +315,17 @@ instructions: |
|
||||
Run `get_diff` against the staged or recent changes, fan out file-reviewers per changed file as usual, and synthesize."
|
||||
```
|
||||
|
||||
Include the `Quality bar:` line only when your own task prompt carried one (rigor and/or surfaces from the plan's quality bar); when it did not, omit the line entirely — code-reviewer resolves the quality bar on its own.
|
||||
|
||||
### 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.
|
||||
- **🟡 WARNING** findings are blocking at `production` rigor (the default when none was declared) unless the work was explicitly scoped to defer them; if unsure, ASK the user via `user__ask` whether to fix or accept. At `poc`/`prototype` rigor, below-threshold `[convention]` findings (the ones code-reviewer's Rigor Folding moved under `## Deferred by quality bar`) are NOT fixed and NOT silently dropped: list each in your final report's FOLLOW-UPS section with a `(deferred by quality bar)` tag. 🔴 blocks at every rigor — rigor never lowers that bar.
|
||||
- **🟢 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.
|
||||
|
||||
**Rejecting a `[convention]` finding.** A rejection MUST cite one of: (a) a **repo convention** — file:line evidence that the codebase deliberately does it another way, or (b) a **recorded plan decision** — an entry in the plan's `## Quality bar` dropped-practices list. Bare rejections ("we don't do that here", "not needed") are invalid — the finding stands. NEVER rejectable: 🔴 findings and `[correctness]` findings. Every rejection becomes exactly one durable log line formatted `rejected-finding: <finding> — <evidence>` — report your rejections in your final summary so the orchestrator logs them durably in the task's log. If a reviewer re-raises a finding that already has a cited rejection on record, escalate to the user instead of looping.
|
||||
|
||||
### 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.
|
||||
@@ -363,6 +375,8 @@ instructions: |
|
||||
- `standard` (default) — anything that will be deployed, shared, committed to a shared repo, or built upon. Blocks Critical + High.
|
||||
- `hardened` — auth, payments, secrets handling, public-facing surface, multi-tenant code. Blocks Critical + High + Medium.
|
||||
|
||||
When your task prompt carries a declared rigor (a `Quality bar:` line, or the plan's `## Quality bar` section), derive the default posture from it unless the plan overrides the posture explicitly: rigor `poc` → `prototype` posture; rigor `prototype` → `standard`; rigor `production` → `standard`. `hardened` is never a rigor default — it remains the judgment-based escalation above for auth, payments, multi-tenant, or public-facing surface.
|
||||
|
||||
When in doubt, use `standard`. Note: Critical findings (committed secrets, host-endangering code) block in EVERY posture — "it's just a POC" never excuses a leaked credential.
|
||||
|
||||
**Spawn pattern** (the prompt IS its whole context — include posture and deployment context):
|
||||
@@ -384,13 +398,86 @@ instructions: |
|
||||
- **`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.
|
||||
- **Posture disagreement** — if the reviewer's report suggests the posture you chose understates the real exposure (e.g. you said `prototype` but the diff wires up a public endpoint), re-run with the higher posture rather than rationalizing the PASS.
|
||||
|
||||
Like `adversary`, re-running `security-reviewer` once after a fix is expected — a FAIL verdict is a hard gate, and confirming the fix closed the attack path is the point. Run all applicable reviewers (`code-reviewer`, `adversary`, `security-reviewer`) — they cover disjoint failure modes; one passing says nothing about the others.
|
||||
Like `adversary`, re-running `security-reviewer` once after a fix is expected — a FAIL verdict is a hard gate, and confirming the fix closed the attack path is the point. Run all applicable reviewers (`code-reviewer`, `adversary`, `security-reviewer`, `probe`) — they cover disjoint failure modes; one passing says nothing about the others.
|
||||
|
||||
### Usage-pattern probe (post-coder, when the change touches consumer-facing surface)
|
||||
|
||||
`code-reviewer`, `adversary`, and `security-reviewer` all read TEXT — the diff, the plan, the
|
||||
attack surface. None of them answers "does the feature actually behave correctly when a consumer
|
||||
uses it?" Spawn `probe` when the change touches consumer-facing surface. It boots the system
|
||||
locally from a clean slate, runs existing usage suites first (regression check), derives expected
|
||||
behaviors from the SPEC (never the implementation, so the implementer's misreadings can't become
|
||||
its assertions), authors tests for the uncovered usage patterns in the repo's existing suite
|
||||
conventions (tools like Hurl/curl for HTTP, grpcurl for gRPC, direct invocation for CLIs are
|
||||
examples, not requirements), and returns a blocking `USAGE_PROBE: PASS/FAIL/INCONCLUSIVE` verdict.
|
||||
|
||||
**When to spawn it** — ANY of these:
|
||||
|
||||
1. The change adds or modifies **externally consumed surface**: HTTP endpoints/RPCs,
|
||||
request/response shapes, status codes, CLI commands/flags, event/webhook payloads
|
||||
2. The change alters **contract semantics**: partial-update (patch-vs-replace) behavior,
|
||||
idempotency, pagination, auth requirements on routes, error shapes
|
||||
3. **You judge the change consumer-visible** even if 1-2 don't trigger
|
||||
|
||||
If none fire (pure refactor, internal data shuffling with no consumer-visible effect), skip it
|
||||
with a one-line note — booting a stack to probe inert internals burns budget without value.
|
||||
|
||||
**Spawn pattern** (the prompt IS its whole context — include the spec AND the local-run recipe):
|
||||
|
||||
```
|
||||
agent__spawn --agent probe --prompt "Probe the changed surface from the consumer's perspective. Return PASS/FAIL/INCONCLUSIVE.
|
||||
|
||||
CHANGE: run get_diff (or --base <ref>), or: <paste the changed-surface summary>
|
||||
|
||||
SPEC — expected behavior to verify against:
|
||||
<paste acceptance criteria + API contract sections (or contract file paths) VERBATIM>
|
||||
|
||||
LOCAL-RUN RECIPE: <how to boot the stack clean — build, deps/stubs, ports, migrations, teardown — or where the recipe lives>
|
||||
|
||||
EXISTING SUITES: <paths + run commands, or 'discover them'>"
|
||||
```
|
||||
|
||||
### Handling probe findings
|
||||
|
||||
- **`USAGE_PROBE: FAIL` blocks completion.** Do not mark the task done. Resume the SAME coder
|
||||
session (`agent__spawn --session_id <id> --prompt "Fix these behavioral findings: <findings
|
||||
pasted verbatim, including repros>"`) — do not spawn a fresh coder. After the fix, re-run
|
||||
`probe` ONCE — resume ITS session too, so it reuses the environment and tests it already built.
|
||||
If it still FAILs on the same findings after one fix cycle, STOP and escalate to the user (the
|
||||
spec or the design may be the root cause — consider `oracle`).
|
||||
- **`USAGE_PROBE: PASS`** — proceed. Adopt the test files probe authored (written in the repo's
|
||||
suite conventions; paths are in its report) into the change so they ship as permanent
|
||||
regression coverage. Surface any stale-test or recipe observations to the user.
|
||||
- **`USAGE_PROBE: INCONCLUSIVE`** — the ENVIRONMENT, not the code, is the blocker. Never treat it
|
||||
as PASS or FAIL. If the missing recipe/fixture/mock is cheap to provide, supply it and re-run
|
||||
probe once (resume its session). Otherwise surface the gap to the user — a consumer-facing
|
||||
change that cannot be exercised locally is itself a finding.
|
||||
- **Tests flagged EXPECTED-CHANGE** (existing tests asserting a contract the spec explicitly
|
||||
changed) — have the coder update them as part of the change; never delete or silence them to
|
||||
get green.
|
||||
|
||||
Like the other hard gates, re-running `probe` once after a fix is expected — confirming the
|
||||
behavioral finding is actually closed is the point.
|
||||
|
||||
### Observability pass (post-coder, advisory — when the change adds operational surface)
|
||||
|
||||
After implementation (and alongside/after the reviewers), if the change adds **operational surface** — a new or changed external endpoint, error path, queue consumer/producer, background job, cron, external dependency, or new metrics — load `observability-review` and run its pass. If none of these apply, skip with a one-line note.
|
||||
|
||||
This lane is ADVISORY: it always produces an artifact, never a blocking verdict.
|
||||
|
||||
1. Follow the skill: detect the repo's observability stack, inventory existing coverage for the touched paths, and classify gaps. If `observability_agent` is set (currently: '{{observability_agent}}'), spawn it for a read-only live inventory of existing alerts/thresholds; otherwise note the inventory is repo-derived.
|
||||
2. **Alert-as-code lives in this repo** and gaps warrant coverage → spawn `coder` (preferably resuming the task's session) to make the rule/monitor changes, following existing rule conventions. These are ordinary code changes — the usual review gates apply to them.
|
||||
3. **Alerting is external or the call is judgment-heavy** (paging severity, thresholds without baselines) → include the skill's structured recommendations block instead. Never touch external alerting systems.
|
||||
4. Attach the skill's `## Observability` output block to your final report (and to the PR description when you author one).
|
||||
|
||||
Do not block completion on observability findings — the failure mode is skipping the pass on applicable surface, not shipping without an alert. Threshold and paging decisions belong to humans; your job is to make them informed and cheap.
|
||||
|
||||
## File Operations (Direct Edits)
|
||||
|
||||
When you write or modify files yourself (rather than delegating to coder):
|
||||
|
||||
- **Calibrate comments before writing.** Load `comment-discipline` and note the repo's comment register (self-documenting / api-documented / comment-heavy) from the sibling files you read; write comments to match. When the signal is weak, write NO comment.
|
||||
- **Calibrate logging before writing.** When the change touches boundaries, error paths, jobs, or state transitions, load `logging-discipline` and note the repo's logging register (logger, message style, payload vs IDs, level semantics) from the same sibling reads; match it. No discernible convention → its best-judgment defaults. Never leave a new error path silently swallowed, and never delete existing log lines as drive-by cleanup.
|
||||
- **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:
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
# Coyote configuration. Generated by the first-run wizard.
|
||||
# Every setting is listed with its effective value and a short description.
|
||||
# For richer examples of each section, see
|
||||
# https://github.com/Dark-Alex-17/coyote/blob/main/config.example.yaml
|
||||
|
||||
# ---- LLM ----
|
||||
__MODEL_BLOCK__
|
||||
temperature: null # Set default temperature parameter (0, 1)
|
||||
top_p: null # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model
|
||||
|
||||
# ---- Behavior ----
|
||||
dry_run: false # Display the messages that would be sent to the LLM without actually sending them
|
||||
stream: true # Controls whether to use the stream-style APIs when querying for completions from LLM clients
|
||||
save: true # Indicates whether to persist the conversation to messages.md for posterity
|
||||
keybindings: emacs # Choose keybinding style (emacs, vi)
|
||||
editor: null # Specifies the editor used to edit the input buffer or session. (e.g. vim, emacs, nano, hx). Defaults to $EDITOR
|
||||
wrap: auto # Controls text wrapping (no, auto, <max-width>)
|
||||
wrap_code: false # Enables or disables the wrapping of code blocks
|
||||
|
||||
# ---- Vault ----
|
||||
# See the [Vault documentation](https://github.com/Dark-Alex-17/coyote/wiki/Vault) for more information on the Coyote vault.
|
||||
#
|
||||
# The secrets_provider tells Coyote where to read and write secrets referenced via {{SECRET_NAME}} syntax.
|
||||
#
|
||||
# Shorthand: set vault_password_file to enable the local provider with that password
|
||||
# file (it cannot be a secret template).
|
||||
#
|
||||
# Explicit: set secrets_provider to one of the supported types below. When secrets_provider is set,
|
||||
# vault_password_file is ignored. Note: secrets_provider itself cannot use secret template syntax.
|
||||
# The vault must be initialized before any secrets can be resolved.
|
||||
#
|
||||
# Local (same as the shorthand above):
|
||||
# secrets_provider:
|
||||
# type: local
|
||||
# password_file: ~/.coyote_password
|
||||
#
|
||||
# AWS Secrets Manager (requires an authenticated AWS CLI; see `aws sso login` or `aws configure`):
|
||||
# secrets_provider:
|
||||
# type: aws_secrets_manager
|
||||
# aws_profile: default
|
||||
# aws_region: us-east-1
|
||||
#
|
||||
# GCP Secret Manager (requires `gcloud auth application-default login`):
|
||||
# secrets_provider:
|
||||
# type: gcp_secret_manager
|
||||
# gcp_project_id: my-project-id
|
||||
#
|
||||
# Azure Key Vault (requires `az login`):
|
||||
# secrets_provider:
|
||||
# type: azure_key_vault
|
||||
# vault_name: my-vault-name
|
||||
#
|
||||
# gopass (requires the `gopass` CLI to be installed and initialized):
|
||||
# secrets_provider:
|
||||
# type: gopass
|
||||
# store: my-store # Optional; omit to use the default store
|
||||
#
|
||||
# 1Password (requires the `op` CLI to be installed and signed in via `op signin`):
|
||||
# secrets_provider:
|
||||
# type: one_password
|
||||
# vault: Production # Optional; omit to use the default vault
|
||||
# account: my.1password.com # Optional; omit to use the default account
|
||||
__SECRETS_BLOCK__
|
||||
|
||||
# ---- Function Calling ----
|
||||
# See the [Tools documentation](https://github.com/Dark-Alex-17/coyote/wiki/Tools) for more details
|
||||
function_calling_support: true # Enables or disables function calling (globally)
|
||||
mapping_tools: {} # Alias for a tool or toolset
|
||||
# Example:
|
||||
# mapping_tools:
|
||||
# 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
|
||||
visible_tools: null # Which tools are visible to be compiled (and are thus able to be defined in 'enabled_tools').
|
||||
# Null/missing = all tools in the global tools dir are visible; [] = none;
|
||||
# an explicit list makes only those tools visible.
|
||||
# Example:
|
||||
# visible_tools:
|
||||
# - execute_command.sh
|
||||
# - fs_cat.sh
|
||||
# - fs_ls.sh
|
||||
|
||||
# ---- Skills ----
|
||||
# Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation.
|
||||
# See the [Skills documentation](https://github.com/Dark-Alex-17/coyote/wiki/Skills) for more details.
|
||||
skills_enabled: true # Master switch. Set to false to hide all skill management tools from the model.
|
||||
# Skills also require `function_calling_support: true` above to work at all.
|
||||
enabled_skills: null # Which skills are available by default (no role/agent/session active). null = all visible.
|
||||
# Accepts either a YAML list or a comma-separated string.
|
||||
# Example (list form):
|
||||
# enabled_skills:
|
||||
# - git-master
|
||||
# - ai-slop-remover
|
||||
# Example (comma-separated form):
|
||||
# enabled_skills: git-master,ai-slop-remover
|
||||
visible_skills: null # The universe of skills allowed to be enabled in any context. null = all installed.
|
||||
# Example:
|
||||
# visible_skills:
|
||||
# - ai-slop-remover
|
||||
# - code-review
|
||||
# - git-master
|
||||
|
||||
# ---- Macros ----
|
||||
# Macros are Coyote's custom commands: named sequences of REPL commands and prompts, invoked directly by name
|
||||
# (a macro file named `review.yaml` runs as `.review [args]`; built-in commands always win a name collision).
|
||||
# Workspace-local macros in `.coyote/macros/` shadow same-named global macros (skip them with --no-workspace-macros).
|
||||
# See the [Macros documentation](https://github.com/Dark-Alex-17/coyote/wiki/Macros) for more details.
|
||||
enabled_macros: null # Which macros are invocable by default (no role/agent/session active). null = all visible.
|
||||
# An empty list means NO macros are invocable. Accepts either a YAML list or a
|
||||
# comma-separated string. Roles, agents, and sessions may define their own
|
||||
# `enabled_macros`; the most specific active one wins (session > agent > role > global).
|
||||
# Example (list form):
|
||||
# enabled_macros:
|
||||
# - generate-commit-message
|
||||
# Example (comma-separated form):
|
||||
# enabled_macros: generate-commit-message,review
|
||||
|
||||
# ---- MCP Servers ----
|
||||
# See the [MCP Servers documentation](https://github.com/Dark-Alex-17/coyote/wiki/MCP-Servers) for more details
|
||||
mcp_server_support: true # Enables or disables MCP servers (globally)
|
||||
mapping_mcp_servers: {} # Alias for an MCP server or set of servers
|
||||
# Example:
|
||||
# mapping_mcp_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
|
||||
mcp_tools: null # Per-server MCP tool allowlists (glob patterns: * and ? supported).
|
||||
# Tools that match no pattern are hidden from the model as if they
|
||||
# don't exist. Stacks with the other allowlist layers (mcp.json
|
||||
# `allowedTools`, role, agent, session, skill, graph node). Every
|
||||
# configured layer must allow a tool, so layers only ever narrow.
|
||||
# An empty list blocks all of a server's tools.
|
||||
# Example:
|
||||
# mcp_tools:
|
||||
# github:
|
||||
# - get_*
|
||||
# - list_*
|
||||
# slack: []
|
||||
|
||||
# ---- Auto-Continue (Todo System) ----
|
||||
# The auto-continue system provides built-in task tracking for improved reliability.
|
||||
# When enabled, the model can create todo lists and the system will automatically
|
||||
# prompt it to continue when incomplete tasks remain.
|
||||
# See the [Todo System documentation](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System) for more information
|
||||
auto_continue: false # Enable automatic continuation when incomplete todos remain (default: false)
|
||||
max_auto_continues: 10 # Maximum number of automatic continuations before stopping (default: 10)
|
||||
inject_todo_instructions: true # Inject default todo usage instructions into the system prompt (default: true)
|
||||
continuation_prompt: null # Custom prompt used when auto-continuing. If null, uses built-in default
|
||||
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
|
||||
|
||||
# ---- Prelude ----
|
||||
repl_prelude: null # Set a default session or role for REPL mode to use (e.g. role:<name>, session:<name>, <session>:<role>)
|
||||
cmd_prelude: null # Set a default session or role for CMD mode to use (e.g. role:<name>, session:<name>, <session>:<role>)
|
||||
agent_session: null # Set a session to use when starting an agent (e.g. temp, default)
|
||||
|
||||
# ---- Session ----
|
||||
# See the [Session documentation](https://github.com/Dark-Alex-17/coyote/wiki/Sessions) for more information
|
||||
save_session: null # Controls the persistence of the session. If true, auto save; if false, don't auto-save; if null, ask the user what to do
|
||||
compression_threshold: 4000 # Compress the session when the token count reaches or exceeds this threshold
|
||||
compression_keep_last: 0 # Number of most-recent messages to keep visible after compression (0 = compress all messages)
|
||||
summarization_prompt: null # The text prompt used for creating a concise summary of session messages. If null, uses built-in default
|
||||
summary_context_prompt: null # The text prompt used for including the summary of the entire session as context to the model. If null, uses built-in default
|
||||
max_tool_result_chars: null # Cap on tool result characters forwarded to the model per call (null = no cap)
|
||||
max_concurrent_jobs: null # Max background jobs (`job__*` tools) running at once per context (null = 5; 0 disables background jobs entirely)
|
||||
|
||||
# ---- Memory ----
|
||||
# See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information.
|
||||
# Memory is opt-in by workspace presence (`.coyote/memory/MEMORY.md`) and global
|
||||
# presence (`<config_dir>/memory/MEMORY.md`). Set `memory: false` to disable
|
||||
# even when memory files exist. The cascade is: agent > session > role > app.
|
||||
# Bootstrap with `coyote --init-memory [global|workspace]` to create the marker file
|
||||
# the LLM needs before it will write any memory.
|
||||
memory: null # null = enabled when memory exists on disk; true = force on; false = force off
|
||||
memory_cap_with_tools: null # Char cap for injected memory when function calling is available (null = 6000).
|
||||
# Only MEMORY.md indexes are injected; the LLM uses memory__read to fetch drill files.
|
||||
memory_cap_without_tools: null # Char cap when function calling is unavailable (null = 12000).
|
||||
# Indexes plus drill file bodies are injected up to this cap.
|
||||
|
||||
# ---- Workspace Instructions ----
|
||||
# Human-curated project instructions injected read-only into the system prompt, in full.
|
||||
# Coyote walks up from the current directory and injects the first match from the file
|
||||
# chain below (per directory, in order). Scaffold with `coyote --init-instructions`.
|
||||
# Disable per-invocation with --no-workspace-instructions, or override the chain with
|
||||
# repeatable --workspace-instructions-file flags.
|
||||
workspace_instructions: null # null/true = inject when an instructions file exists; false = never inject
|
||||
workspace_instructions_files: null # File name chain to search, in priority order.
|
||||
# Default: [COYOTE.md, AGENTS.md, CLAUDE.md, GEMINI.md]
|
||||
# Set to a custom list to reorder or drop fallbacks, e.g.:
|
||||
# workspace_instructions_files: [COYOTE.md]
|
||||
|
||||
# ---- RAG ----
|
||||
# See the [RAG Docs](https://github.com/Dark-Alex-17/coyote/wiki/RAG) for more details.
|
||||
rag_embedding_model: null # Specifies the embedding model used for context retrieval
|
||||
rag_reranker_model: null # Specifies the reranker model used for sorting retrieved documents; Coyote uses Reciprocal Rank Fusion by default
|
||||
rag_top_k: 5 # Specifies the number of documents to retrieve for answering queries
|
||||
rag_chunk_size: null # Defines the size of chunks for document processing in characters
|
||||
rag_chunk_overlap: null # Defines the overlap between chunks
|
||||
rag_template: null # Defines the query structure using variables like __CONTEXT__, __SOURCES__, and __INPUT__
|
||||
# to tailor searches to specific needs. If null, uses built-in default
|
||||
rag_extractor_model: null # LLM model for graph-based entity/relationship extraction; when set, enables a graph RAG signal alongside vector and BM25
|
||||
rag_extractor_prompt: null # Custom extraction prompt template; must contain __CHUNK__ placeholder; defaults to built-in prompt when null
|
||||
rag_graph_hops: 1 # Number of hops to expand from matched entities at query time (0 = seed nodes only; 1 = direct neighbors; increase for denser graphs)
|
||||
|
||||
# Define document loaders to control how RAG and `.file`/`--file` load files of specific formats.
|
||||
document_loaders: {}
|
||||
# You can add custom loaders using the following syntax:
|
||||
# <file-extension>: <command-to-load-the-file>
|
||||
# Note: Use `$1` for input file and `$2` for output file. If `$2` is omitted, use stdout as output.
|
||||
# Examples:
|
||||
# document_loaders:
|
||||
# pdf: 'pdftotext $1 -' # https://poppler.freedesktop.org
|
||||
# docx: 'pandoc --to plain $1' # https://pandoc.org
|
||||
# jina: 'curl -fsSL https://r.jina.ai/$1 -H "Authorization: Bearer {{JINA_API_KEY}}"' # Requires a Jina API key in the Coyote vault
|
||||
|
||||
# ---- Appearance ----
|
||||
highlight: true # Controls syntax highlighting
|
||||
raw_markdown: false # When true, render markdown as raw text with syntax highlighting only. When false (default), transforms markdown syntax (headings, bold, lists, etc.) into styled terminal output
|
||||
theme: null # null = the built-in dark theme; set to `light` for the built-in light theme.
|
||||
# Custom themes: place a `dark.tmTheme` or `light.tmTheme` file in the Coyote config
|
||||
# directory and it is used in place of the corresponding built-in.
|
||||
|
||||
# ---- REPL Prompt ----
|
||||
# Custom REPL left/right prompts; see the [REPL Prompt Documentation](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt) for more information
|
||||
left_prompt: null # If null, uses the built-in default:
|
||||
# '{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} '
|
||||
right_prompt: null # If null, uses the built-in default:
|
||||
# '{color.cyan}{?reasoning_effort [{reasoning_effort}] }{color.purple}{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}'
|
||||
|
||||
# ---- Miscellaneous ----
|
||||
user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version>
|
||||
save_shell_history: true # Whether to save shell execution command to the history file
|
||||
sync_models_url: null # URL to sync model changes from. If null, uses the built-in default:
|
||||
# https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml
|
||||
|
||||
# ---- Clients ----
|
||||
# See the [Clients documentation](https://github.com/Dark-Alex-17/coyote/wiki/Clients) for more details
|
||||
#
|
||||
# All clients have the following configuration:
|
||||
# - type: xxxx
|
||||
# name: xxxx # Only use it to distinguish clients with the same client type. Optional
|
||||
# models:
|
||||
# - name: xxxx # Chat model
|
||||
# max_input_tokens: 100000
|
||||
# supports_vision: true
|
||||
# supports_function_calling: true
|
||||
# - name: xxxx # Embedding model
|
||||
# type: embedding
|
||||
# default_chunk_size: 1500
|
||||
# max_batch_size: 100
|
||||
# - name: xxxx # Reranker model
|
||||
# type: reranker
|
||||
# patch: # Patch API calls
|
||||
# chat_completions: # API type; Possible values: chat_completions, embeddings, and rerank
|
||||
# <regex>: # The regex to match model names, e.g. '.*' 'gpt-4o' 'gpt-4o|gpt-4-.*'
|
||||
# url: '' # Patch request URL
|
||||
# body: # Patch request body
|
||||
# <json>
|
||||
# headers: # Patch request headers
|
||||
# <key>: <value>
|
||||
# extra:
|
||||
# proxy: socks5://127.0.0.1:1080 # Set proxy
|
||||
# connect_timeout: 10 # Set timeout in seconds for connect to api
|
||||
# read_timeout: 300 # Set timeout in seconds for a read stall (no bytes received); 0 disables (default: 300)
|
||||
__CLIENTS_BLOCK__
|
||||
@@ -27,14 +27,30 @@ def _ensure_cwd_venv():
|
||||
_ensure_cwd_venv()
|
||||
|
||||
|
||||
def resolve_dir(env_name, default_path):
|
||||
"""Resolve a directory at run time.
|
||||
|
||||
Prefer the override env var when set, otherwise fall back to the default
|
||||
path derived from this script's own location, so the shim keeps working
|
||||
when the config dir moves or is shared across environments with different
|
||||
home directories.
|
||||
"""
|
||||
value = os.environ.get(env_name)
|
||||
if value:
|
||||
return value
|
||||
return os.path.normpath(default_path)
|
||||
|
||||
|
||||
def main():
|
||||
(agent_func, raw_data) = parse_argv()
|
||||
agent_data = parse_raw_data(raw_data)
|
||||
|
||||
root_dir = "{config_dir}"
|
||||
setup_env(root_dir, agent_func, raw_data)
|
||||
self_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
agent_dir = os.path.normpath(os.path.join(self_dir, ".."))
|
||||
root_dir = resolve_dir("{root_dir_env}", os.path.join(self_dir, "{root_dir_rel}"))
|
||||
setup_env(root_dir, agent_dir, agent_func, raw_data)
|
||||
|
||||
agent_tools_path = os.path.join(root_dir, "agents/{agent_name}/tools.py")
|
||||
agent_tools_path = os.path.join(agent_dir, "tools.py")
|
||||
run(agent_tools_path, agent_func, agent_data)
|
||||
|
||||
|
||||
@@ -65,12 +81,12 @@ def parse_argv():
|
||||
return agent_func, agent_data
|
||||
|
||||
|
||||
def setup_env(root_dir, agent_func, raw_data):
|
||||
def setup_env(root_dir, agent_dir, agent_func, raw_data):
|
||||
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_ROOT_DIR"] = agent_dir
|
||||
os.environ["LLM_AGENT_CACHE_DIR"] = os.path.join(root_dir, "cache", "{agent_name}")
|
||||
os.environ["LLM_AGENT_RAW_JSON"] = raw_data
|
||||
|
||||
|
||||
@@ -5,13 +5,30 @@
|
||||
set -e
|
||||
|
||||
main() {
|
||||
root_dir="{config_dir}"
|
||||
self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
agent_dir="$(cd "$self_dir/.." && pwd)"
|
||||
root_dir="$(resolve_dir "{root_dir_env}" "$self_dir/{root_dir_rel}")"
|
||||
functions_dir="$(resolve_dir "{functions_dir_env}" "$self_dir/{functions_dir_rel}")"
|
||||
parse_argv "$@"
|
||||
setup_env
|
||||
tools_path="$root_dir/agents/{agent_name}/tools.sh"
|
||||
tools_path="$agent_dir/tools.sh"
|
||||
run
|
||||
}
|
||||
|
||||
# Resolve a directory at run time: prefer the override env var ($1) when set,
|
||||
# otherwise fall back to the default path ($2) derived from this script's own
|
||||
# location, so the shim keeps working when the config dir moves or is shared
|
||||
# across environments with different home directories.
|
||||
resolve_dir() {
|
||||
local override
|
||||
override="$(printenv "$1" 2>/dev/null || true)"
|
||||
if [[ -n "$override" ]]; then
|
||||
echo "$override"
|
||||
else
|
||||
(cd "$2" 2>/dev/null && pwd) || echo "$2"
|
||||
fi
|
||||
}
|
||||
|
||||
parse_argv() {
|
||||
agent_func="$1"
|
||||
if [[ -n "$LLM_TOOL_DATA_FILE" ]] && [[ -f "$LLM_TOOL_DATA_FILE" ]]; then
|
||||
@@ -29,9 +46,9 @@ setup_env() {
|
||||
export LLM_ROOT_DIR="$root_dir"
|
||||
export LLM_AGENT_NAME="{agent_name}"
|
||||
export LLM_AGENT_FUNC="$agent_func"
|
||||
export LLM_AGENT_ROOT_DIR="$LLM_ROOT_DIR/agents/{agent_name}"
|
||||
export LLM_AGENT_ROOT_DIR="$agent_dir"
|
||||
export LLM_AGENT_CACHE_DIR="$LLM_ROOT_DIR/cache/{agent_name}"
|
||||
export LLM_PROMPT_UTILS_FILE="{prompt_utils_file}"
|
||||
export LLM_PROMPT_UTILS_FILE="$functions_dir/utils/prompt-utils.sh"
|
||||
export LLM_AGENT_RAW_JSON="$agent_data"
|
||||
}
|
||||
|
||||
@@ -59,6 +76,10 @@ run() {
|
||||
die "error: no JSON data"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$tools_path" ]]; then
|
||||
die "error: agent tools script not found: $tools_path"
|
||||
fi
|
||||
|
||||
if [[ "$OS" == "Windows_NT" ]]; then
|
||||
set -o igncr
|
||||
tools_path="$(cygpath -w "$tools_path")"
|
||||
@@ -122,4 +143,3 @@ die() {
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
|
||||
@@ -3,17 +3,38 @@
|
||||
// Usage: ./{agent_name}.ts <agent-func> <agent-data>
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { pathToFileURL } from "url";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath, pathToFileURL } from "url";
|
||||
|
||||
function selfDir(): string {
|
||||
if (typeof __dirname !== "undefined") {
|
||||
return __dirname;
|
||||
}
|
||||
return dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
// Resolve a directory at run time: prefer the override env var when set,
|
||||
// otherwise fall back to the default path derived from this script's own
|
||||
// location, so the shim keeps working when the config dir moves or is shared
|
||||
// across environments with different home directories.
|
||||
function resolveDir(envName: string, defaultPath: string): string {
|
||||
const value = process.env[envName];
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
return resolve(defaultPath);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { agentFunc, rawData } = parseArgv();
|
||||
const agentData = parseRawData(rawData);
|
||||
|
||||
const configDir = "{config_dir}";
|
||||
setupEnv(configDir, agentFunc, rawData);
|
||||
const binDir = selfDir();
|
||||
const agentDir = resolve(binDir, "..");
|
||||
const configDir = resolveDir("{root_dir_env}", join(binDir, "{root_dir_rel}"));
|
||||
setupEnv(configDir, agentDir, agentFunc, rawData);
|
||||
|
||||
const agentToolsPath = join(configDir, "agents", "{agent_name}", "tools.ts");
|
||||
const agentToolsPath = join(agentDir, "tools.ts");
|
||||
await run(agentToolsPath, agentFunc, agentData);
|
||||
}
|
||||
|
||||
@@ -48,12 +69,17 @@ function parseArgv(): { agentFunc: string; rawData: string } {
|
||||
return { agentFunc, rawData: agentData };
|
||||
}
|
||||
|
||||
function setupEnv(configDir: string, agentFunc: string, rawData: string): void {
|
||||
function setupEnv(
|
||||
configDir: string,
|
||||
agentDir: string,
|
||||
agentFunc: string,
|
||||
rawData: 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_ROOT_DIR"] = agentDir;
|
||||
process.env["LLM_AGENT_CACHE_DIR"] = join(configDir, "cache", "{agent_name}");
|
||||
process.env["LLM_AGENT_RAW_JSON"] = rawData;
|
||||
}
|
||||
|
||||
@@ -27,14 +27,30 @@ def _ensure_cwd_venv():
|
||||
_ensure_cwd_venv()
|
||||
|
||||
|
||||
def resolve_dir(env_name, default_path):
|
||||
"""Resolve a directory at run time.
|
||||
|
||||
Prefer the override env var when set, otherwise fall back to the default
|
||||
path derived from this script's own location, so the shim keeps working
|
||||
when the config dir moves or is shared across environments with different
|
||||
home directories.
|
||||
"""
|
||||
value = os.environ.get(env_name)
|
||||
if value:
|
||||
return value
|
||||
return os.path.normpath(default_path)
|
||||
|
||||
|
||||
def main():
|
||||
raw_data = parse_argv()
|
||||
tool_data = parse_raw_data(raw_data)
|
||||
|
||||
root_dir = "{root_dir}"
|
||||
self_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
root_dir = resolve_dir("{root_dir_env}", os.path.join(self_dir, "{root_dir_rel}"))
|
||||
functions_dir = resolve_dir("{functions_dir_env}", os.path.join(self_dir, "{functions_dir_rel}"))
|
||||
setup_env(root_dir, raw_data)
|
||||
|
||||
tool_path = "{tool_path}.py"
|
||||
tool_path = os.path.join(functions_dir, "tools", "{function_name}.py")
|
||||
run(tool_path, "run", tool_data)
|
||||
|
||||
|
||||
|
||||
@@ -5,13 +5,29 @@
|
||||
set -e
|
||||
|
||||
main() {
|
||||
root_dir="{root_dir}"
|
||||
self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
root_dir="$(resolve_dir "{root_dir_env}" "$self_dir/{root_dir_rel}")"
|
||||
functions_dir="$(resolve_dir "{functions_dir_env}" "$self_dir/{functions_dir_rel}")"
|
||||
parse_argv "$@"
|
||||
setup_env
|
||||
tool_path="{tool_path}.sh"
|
||||
tool_path="$functions_dir/tools/{function_name}.sh"
|
||||
run
|
||||
}
|
||||
|
||||
# Resolve a directory at run time: prefer the override env var ($1) when set,
|
||||
# otherwise fall back to the default path ($2) derived from this script's own
|
||||
# location, so the shim keeps working when the config dir moves or is shared
|
||||
# across environments with different home directories.
|
||||
resolve_dir() {
|
||||
local override
|
||||
override="$(printenv "$1" 2>/dev/null || true)"
|
||||
if [[ -n "$override" ]]; then
|
||||
echo "$override"
|
||||
else
|
||||
(cd "$2" 2>/dev/null && pwd) || echo "$2"
|
||||
fi
|
||||
}
|
||||
|
||||
parse_argv() {
|
||||
if [[ -n "$LLM_TOOL_DATA_FILE" ]] && [[ -f "$LLM_TOOL_DATA_FILE" ]]; then
|
||||
tool_data="$(cat "$LLM_TOOL_DATA_FILE")"
|
||||
@@ -28,7 +44,7 @@ setup_env() {
|
||||
export LLM_ROOT_DIR="$root_dir"
|
||||
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_PROMPT_UTILS_FILE="$functions_dir/utils/prompt-utils.sh"
|
||||
export LLM_TOOL_RAW_JSON="$tool_data"
|
||||
}
|
||||
|
||||
@@ -56,6 +72,10 @@ run() {
|
||||
die "error: no JSON data"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$tool_path" ]]; then
|
||||
die "error: tool script not found: $tool_path"
|
||||
fi
|
||||
|
||||
if [[ "$OS" == "Windows_NT" ]]; then
|
||||
set -o igncr
|
||||
tool_path="$(cygpath -w "$tool_path")"
|
||||
|
||||
@@ -3,17 +3,41 @@
|
||||
// Usage: ./{function_name}.ts <tool-data>
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { pathToFileURL } from "url";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath, pathToFileURL } from "url";
|
||||
|
||||
function selfDir(): string {
|
||||
if (typeof __dirname !== "undefined") {
|
||||
return __dirname;
|
||||
}
|
||||
return dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
// Resolve a directory at run time: prefer the override env var when set,
|
||||
// otherwise fall back to the default path derived from this script's own
|
||||
// location, so the shim keeps working when the config dir moves or is shared
|
||||
// across environments with different home directories.
|
||||
function resolveDir(envName: string, defaultPath: string): string {
|
||||
const value = process.env[envName];
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
return resolve(defaultPath);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const rawData = parseArgv();
|
||||
const toolData = parseRawData(rawData);
|
||||
|
||||
const rootDir = "{root_dir}";
|
||||
const binDir = selfDir();
|
||||
const rootDir = resolveDir("{root_dir_env}", join(binDir, "{root_dir_rel}"));
|
||||
const functionsDir = resolveDir(
|
||||
"{functions_dir_env}",
|
||||
join(binDir, "{functions_dir_rel}"),
|
||||
);
|
||||
setupEnv(rootDir, rawData);
|
||||
|
||||
const toolPath = "{tool_path}.ts";
|
||||
const toolPath = join(functionsDir, "tools", "{function_name}.ts");
|
||||
await run(toolPath, "run", toolData);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,5 +20,12 @@ main() {
|
||||
trap "rm -f '$script'" EXIT
|
||||
# shellcheck disable=SC2154
|
||||
printf '%s\n' "$argc_command" > "$script"
|
||||
bash -e -o pipefail "$script" >> "$LLM_OUTPUT"
|
||||
# No -e: the command gets standard interactive-shell semantics — the last
|
||||
# statement decides the exit code, so trailing guards like `; exit 0` work
|
||||
# and an intermediate non-zero status (grep with no matches, a failing
|
||||
# test run being inspected) cannot abort the script mid-way. pipefail is
|
||||
# kept so a failing pipeline stage still surfaces in the exit code. 2>&1:
|
||||
# the harness only returns $LLM_OUTPUT on success, so without it stderr
|
||||
# (git push, cargo progress, curl -v) vanishes from successful calls.
|
||||
bash -o pipefail "$script" >> "$LLM_OUTPUT" 2>&1
|
||||
}
|
||||
|
||||
@@ -25,7 +25,11 @@ main() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local grep_args=(-nH --color=never)
|
||||
# --binary-files=text: GNU grep's binary heuristic false-positives on valid
|
||||
# UTF-8 source files >=128KiB when a multibyte character straddles an
|
||||
# internal read-buffer boundary, silently returning zero matches. This tool
|
||||
# only searches text, so always force text mode.
|
||||
local grep_args=(-nH --color=never --binary-files=text)
|
||||
|
||||
if [[ -d "$search_path" ]]; then
|
||||
# Use -r (not -R) so symlinks to directories are NOT followed - this avoids
|
||||
|
||||
@@ -15,6 +15,11 @@ set -e
|
||||
# - vertexai:gemini-*
|
||||
# - perplexity:*
|
||||
# - ernie:*
|
||||
# - claude:* (Anthropic native web_search server tool)
|
||||
# - openai:gpt-4o-search-preview (and -mini-; requires an api-key openai
|
||||
# client — the codex OAuth path uses the
|
||||
# Responses API where this parameter
|
||||
# does not exist)
|
||||
# @env LLM_OUTPUT=/dev/stdout The output path
|
||||
|
||||
# shellcheck disable=SC2154
|
||||
@@ -30,6 +35,13 @@ main() {
|
||||
}'
|
||||
elif [[ "$client" == "ernie" ]]; then
|
||||
export COYOTE_PATCH_ERNIE_CHAT_COMPLETIONS='{".*":{"body":{"web_search":{"enable":true}}}}'
|
||||
elif [[ "$client" == "claude" ]]; then
|
||||
export COYOTE_PATCH_CLAUDE_CHAT_COMPLETIONS='{".*":{"body":{"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}]}}}'
|
||||
elif [[ "$client" == "openai" ]]; then
|
||||
# Chat Completions native search exists only on the search-preview
|
||||
# models; the regex scopes the patch so other OpenAI models run
|
||||
# unpatched instead of erroring on an unsupported parameter.
|
||||
export COYOTE_PATCH_OPENAI_CHAT_COMPLETIONS='{"gpt-4o.*search-preview.*":{"body":{"web_search_options":{}}}}'
|
||||
fi
|
||||
|
||||
coyote -m "$WEB_SEARCH_MODEL" "$argc_query" >> "$LLM_OUTPUT"
|
||||
|
||||
@@ -9,11 +9,11 @@ kind: sandbox
|
||||
name: coyote
|
||||
displayName: Coyote
|
||||
description: >
|
||||
An all-in-one, batteries-included LLM CLI tool featuring Shell Assistant,
|
||||
The batteries-included runtime for LLMs, featuring Shell Assistant,
|
||||
CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros.
|
||||
|
||||
sandbox:
|
||||
image: 'darkalex17/coyote:v0.8.3'
|
||||
image: 'darkalex17/coyote:v0.10.1'
|
||||
entrypoint: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote']
|
||||
|
||||
permissions:
|
||||
@@ -370,4 +370,4 @@ agentInstructions:
|
||||
Useful first-run commands:
|
||||
- `coyote --info` # show config paths and resolved settings
|
||||
- `coyote --list-secrets` # initialise the local vault
|
||||
- `coyote --authenticate <client>` # OAuth flow (Claude Pro/Max, Gemini)
|
||||
- `coyote --authenticate <client>` # OAuth flow (Claude Pro/Max, Gemini)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
description: Review CI/CD pipeline definitions - action/step pinning by SHA, token and credential permission scoping, secret exposure to fork-PR triggers, and cross-branch cache poisoning. Load when a diff touches workflow/pipeline files such as .github/workflows/*, GitLab CI config, or equivalent pipeline definitions. Findings fold into the standard code-review severity taxonomy. Grants read-only filesystem access for tracing workflows, triggers, and permission blocks.
|
||||
enabled_tools: fs_read, fs_grep, fs_glob, fs_cat, fs_ls
|
||||
---
|
||||
You are reviewing CI/CD pipeline definitions. The generic correctness checklist asks "does this pipeline run?"; you ask **"what can this pipeline be made to do by someone who controls an input to it — a tag, a fork PR, a cache key?"** A workflow file is production code with production credentials that runs third-party code on every push, yet it is otherwise reviewed by nobody. Most pipeline incidents are not broken builds — they are a mutable action tag that started doing something new, a token scoped far beyond its job, or a secret handed to code from a fork.
|
||||
|
||||
## When to load this skill
|
||||
|
||||
The diff touches ANY of: workflow/pipeline files — `.github/workflows/*`, GitLab CI config, or equivalent pipeline definitions in other systems. If the diff is application code with unchanged pipelines — unload; this checklist has nothing for you.
|
||||
|
||||
## Marker semantics
|
||||
|
||||
Every checklist item below carries a severity emoji AND a `[convention]` or `[correctness]` marker; both ride in the finding title so downstream tooling can act on them mechanically. `[convention]` findings are rigor-foldable (the orchestrator may lower them under a relaxed quality bar) and rejectable — but ONLY with cited evidence: a repo convention at file:line, or a recorded plan decision. `[correctness]` is reserved for contract breaks; those findings are neither foldable nor rejectable.
|
||||
|
||||
## Linters and mechanized checks
|
||||
|
||||
The review orchestrator runs the domain's mechanized checker — `actionlint` for GitHub Actions workflows; your CONTEXT may already include its output — do not re-derive it. Spend your prose on what the linter cannot reach: whether a token's permissions match what the job actually does, what a fork-triggered run can see, whether a cache key crosses a trust boundary. If the repo plausibly warrants a linter config it lacks (workflow files but no `actionlint` wiring), emit a 🟢 `[convention]` finding naming the gap.
|
||||
|
||||
## The checklist
|
||||
|
||||
Severities below are the production bar. Each item is a context-sensitive question, not an absolute — read the trigger blocks and permission blocks before flagging, and state any exemption you rely on.
|
||||
|
||||
### 1. 🟡 `[convention]` Actions pinned by mutable tag instead of SHA
|
||||
|
||||
Does the diff reference third-party actions or pipeline steps by a mutable tag (`@v4`, `@main`) rather than a full commit SHA? A mutable tag means the code your pipeline runs — with its credentials — can change without any change in your repo; tags have been retargeted maliciously in the wild. The house fix is SHA-pinning with a tag comment (and a bot to update pins). First-party actions from the same repo/org can be exempt per repo convention — cite the convention if you rely on it.
|
||||
|
||||
### 2. 🟡 `[convention]` Token/credential permissions broader than the job needs
|
||||
|
||||
Does each job's token grant match what the job actually does? Look for missing explicit permission blocks (falling back to a broad default), write scopes on jobs that only read, and org-level credentials in jobs that need repo-level access. The finding names the scoped alternative: the specific permissions the job's steps use. A workflow-level broad grant with per-job narrowing is acceptable shape; per-job broad grants "to be safe" are the finding.
|
||||
|
||||
### 3. 🔴 `[convention]` Secrets exposed to fork-PR triggers
|
||||
|
||||
Can a pull request from a fork reach this workflow's secrets? The dangerous shapes: triggers that run with secret access on fork-controlled code (e.g. `pull_request_target` checking out the PR head), secrets passed into steps that execute fork-modified scripts, and label-gated runs where the gate is applied after checkout. This item is co-owned with `security-review`'s supply-chain checklist item — that skill owns the full exploitation analysis; you flag the exposure the moment the trigger/secret/checkout combination makes it possible. The severity stays 🔴 regardless of the declared quality bar — fork-reachable secrets are critical at every rigor, and this item should never be folded down. Workflows that run on fork PRs WITHOUT secrets, or with secrets only after a trusted-code boundary, are the correct shapes — verify the checkout ref before accepting them.
|
||||
|
||||
### 4. 🟢 `[convention]` Cross-branch cache poisoning
|
||||
|
||||
Do cache keys let an untrusted branch write cache entries that a trusted branch (main, release) later restores? Caches written by fork-PR or feature-branch runs and restored by default-branch runs let attacker-influenced artifacts flow into trusted builds. Check the cache key/scope construction and the platform's cache-isolation rules — some platforms already isolate caches by branch with one-way fallback; a pattern the platform provably isolates is exempt, and worth citing.
|
||||
|
||||
## Ground-truth discipline
|
||||
|
||||
- READ the trigger block and permission block of every workflow the diff touches — the risk is almost always in the trigger/checkout/secret combination, not in the step commands.
|
||||
- `fs_grep` sibling workflows for the house idioms (SHA-pinning style, permission-block placement, cache-key construction) and cite the sibling at file:line when flagging deviation.
|
||||
- Check what each referenced action actually is (first-party vs third-party, checkout target) before applying the pinning and fork-exposure items.
|
||||
- Do not assert platform behavior (default permissions, cache isolation) from memory alone when the repo's config could override it — check the org/repo-level settings files if present, and state assumptions otherwise.
|
||||
|
||||
## What this skill does NOT check
|
||||
|
||||
- The full exploitation analysis of exposed secrets, injection via untrusted workflow inputs (`${{ }}` interpolation attacks), and supply-chain trust of the pinned actions themselves → `security-review` (item 3 above is explicitly co-owned with its supply-chain item).
|
||||
- Whether deploy/release steps the pipeline runs are idempotent and safe to re-run → `transactional-integrity`.
|
||||
- Log output conventions of pipeline steps → `logging-discipline`.
|
||||
- Metrics/alerts on pipeline health and deploy outcomes → `observability-review`.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
description: Review the command-line surface contract of a change - exit codes, stdout/stderr channel discipline, help text, non-interactive operation, signal/cleanup behavior, and config precedence. Load when a diff touches argument-parser definitions, the main/entrypoint of a binary, or subcommand modules. Findings fold into the standard code-review severity taxonomy. Grants read-only filesystem access for tracing entrypoints, parsers, and exit paths.
|
||||
enabled_tools: fs_read, fs_grep, fs_glob, fs_cat, fs_ls
|
||||
---
|
||||
You are reviewing a command-line interface. The generic correctness checklist asks "does this command work when a human runs it?"; you ask **"does this command keep its contract with the scripts, pipes, and CI jobs that run it unattended?"** A CLI's real callers are rarely humans at a terminal — they are shell scripts branching on `$?`, pipelines parsing stdout, and cron jobs with no TTY. Most CLI breakage in the wild is not wrong logic; it is a success that exits 1, a diagnostic that corrupts a pipe, or a prompt that hangs a CI job forever.
|
||||
|
||||
## When to load this skill
|
||||
|
||||
The diff touches ANY of: argument-parser definitions (flag/option/subcommand declarations), the `main`/entrypoint of a binary, or subcommand modules. If the diff is library internals behind an unchanged command surface — unload; this checklist has nothing for you.
|
||||
|
||||
## Marker semantics
|
||||
|
||||
Every checklist item below carries a severity emoji AND a `[convention]` or `[correctness]` marker; both ride in the finding title so downstream tooling can act on them mechanically. `[convention]` findings are rigor-foldable (the orchestrator may lower them under a relaxed quality bar) and rejectable — but ONLY with cited evidence: a repo convention at file:line, or a recorded plan decision. `[correctness]` is reserved for contract breaks; those findings are neither foldable nor rejectable.
|
||||
|
||||
## Linters and mechanized checks
|
||||
|
||||
The review orchestrator runs mechanized checks (shell linters, help-text validators); your CONTEXT may already include their output — do not re-derive it. Spend your prose on what linters cannot reach: exit-code semantics on each error path, which stream a message lands on, whether a prompt has an escape hatch. If the repo plausibly warrants a linter config it lacks (shell scripts but no shell linter config), emit a 🟢 `[convention]` finding naming the gap.
|
||||
|
||||
## The checklist
|
||||
|
||||
Severities below are the production bar. Each item is a context-sensitive question, not an absolute — trace the actual exit paths and output calls before flagging.
|
||||
|
||||
### 1. 🔴 `[correctness]` Error paths exiting 0 / success paths exiting non-zero
|
||||
|
||||
Trace every exit path the diff adds or modifies: does each failure propagate a non-zero exit code all the way out of `main`, and does success exit 0? The classic bugs: an error that is printed and then falls through to a normal return; a caught exception that logs and continues; a match arm that swallows a `Result`. Scripts branch on `$?` — an inverted exit code silently corrupts every automation built on this command. This is the exit-code contract; it is never foldable and never rejectable.
|
||||
|
||||
### 2. 🟡 `[convention]` Diagnostics on stdout corrupting pipeable output
|
||||
|
||||
If the command's stdout is (or plausibly will be) piped or parsed — it prints data, JSON, lists, paths — then progress messages, warnings, and diagnostics on stdout corrupt the stream. Do new prints route diagnostics to stderr and reserve stdout for payload? A purely interactive command with no parseable output can be exempt — say so when you rely on that. Which *level and format* diagnostics use is `logging-discipline`'s question; yours is which stream they land on.
|
||||
|
||||
### 3. 🟢 `[convention]` Missing or wrong --help for new flags
|
||||
|
||||
Does every flag, option, and subcommand the diff adds appear in help output with an accurate description? Check the parser declarations: a flag with no help string, a stale description contradicting new behavior, or a new subcommand missing from the top-level help listing. Help text is the CLI's only discoverable documentation.
|
||||
|
||||
### 4. 🟡 `[convention]` Interactive prompt with no non-interactive escape
|
||||
|
||||
Does the diff add a prompt (confirmation, password, selection)? Then there must be a non-interactive path: a flag (`--yes`/`--force`-style), an environment variable, or reading from stdin — and ideally the prompt should detect a missing TTY rather than hang. A prompt with no escape hatch deadlocks CI and cron callers. Check what escape idiom the repo's existing prompts use and whether the new one matches.
|
||||
|
||||
### 5. 🟡 `[convention]` No signal/cleanup handling for long-running commands with temp state
|
||||
|
||||
If the diff adds a long-running command that creates temp files, lockfiles, partial output, or spawns children: what happens on Ctrl-C or SIGTERM? Look for signal handling, cleanup guards (drop/defer/finally/trap), or an idiom the repo already uses. Orphaned locks and half-written files are the finding. Short-lived commands with no temp state are exempt — this item is scoped to commands that hold state long enough for interruption to be a realistic event.
|
||||
|
||||
### 6. 🟢 `[convention]` Config precedence violated or undocumented
|
||||
|
||||
If the command reads configuration from more than one source, the conventional precedence is flag > environment variable > config file. Does the diff's resolution order honor that — and honor whatever order the repo has already established? A new setting that reads only the file when its siblings accept a flag override, or a precedence order documented nowhere, is the finding. Cite the repo's existing resolution code when flagging a deviation.
|
||||
|
||||
## Ground-truth discipline
|
||||
|
||||
- READ the full path from error site to process exit — exit-code bugs live in the propagation, not the error site. `fs_grep` for the exit/return conventions the entrypoint uses.
|
||||
- Check sibling subcommands for the established idioms (stderr usage, prompt escape flags, cleanup guards) — a new subcommand skipping the house pattern is the strongest form of evidence.
|
||||
- Do not flag hypothetical piping of a command that is documented interactive-only; note the assumption instead.
|
||||
|
||||
## What this skill does NOT check
|
||||
|
||||
- Whether argument or path inputs are exploitable (injection, traversal, secrets on the command line) → `security-review`.
|
||||
- Whether the state a command mutates is changed idempotently and atomically under reruns → `transactional-integrity`.
|
||||
- Log levels, formats, and message register of diagnostics → `logging-discipline` (this skill only checks which stream they use).
|
||||
- Metrics and alerting for operationally significant commands → `observability-review`.
|
||||
@@ -31,6 +31,18 @@ Produce a structured proposal (iterate with the user when interactive — load t
|
||||
- **Open questions** — ONLY questions the codebase cannot answer (business rules, priority calls). If none, say "No open questions."
|
||||
- **Task breakdown** — see below.
|
||||
|
||||
## Quality bar round (closes Step 2)
|
||||
|
||||
The last round of Step 2 sets the plan's quality bar. It is grilling-compatible — run it as numbered questions, each carrying a recommended answer, like any other frontier round:
|
||||
|
||||
1. **Propose `rigor`** — one of `poc | prototype | production` (default `production`). Infer the recommended value from the design doc's own language: "spike"/"demo" → `poc`; "iterate"/"internal" → `prototype`; otherwise `production`. Rigor calibrates which review-finding severities BLOCK downstream work: 🔴-critical findings block at EVERY rigor; at `poc`, suggestion/nitpick-level (🟢/💡) convention findings may be dropped from reports entirely. Anything a lower rigor defers is tracked as a follow-up — never silently dropped.
|
||||
2. **Propose `surfaces`** — zero or more of the closed enum: `rest-api`, `cli`, `library`, `worker`, `iac`, `db-migration`, `frontend`, `ci-cd` (`grpc`/`graphql` are aliases for `rest-api`), plus the escape hatch `other:<label>` for anything outside it. Infer from the approach: an HTTP handler → `rest-api`, a schema change → `db-migration`, and so on.
|
||||
3. **Per-surface confirm/drop** — for each accepted surface, present the headline best practices its reviewers will enforce and let the user confirm or drop each one. Every drop demands a one-line reason and lands in the plan's `## Quality bar` section — a dropped practice without a recorded reason WILL be re-litigated by a reviewer.
|
||||
|
||||
For an `other:<label>` surface no reviewer checklist exists, so the lane is: a librarian lookup distills an authoritative best-practice checklist for the label; the user confirms or drops each item; accepted items become task acceptance criteria where possible, otherwise they live under `## Quality bar → Long-tail criteria`. (The architect drives the lookup; this skill documents the shape the results take in the plan.)
|
||||
|
||||
Autonomous runs (no user to grill): take the inferred values, drop nothing, and note "quality bar inferred, not user-confirmed" in the plan.
|
||||
|
||||
## Task breakdown rules
|
||||
|
||||
| Rule | Why |
|
||||
@@ -51,6 +63,8 @@ Write `PLAN-<slug>.md` (kebab-case slug from the topic; verify no collision) to
|
||||
slug: <slug>
|
||||
status: draft # draft | active | implemented
|
||||
created: YYYY-MM-DD
|
||||
rigor: production # poc | prototype | production; omitted = production
|
||||
surfaces: [] # from the closed enum and/or other:<label>; omitted = []
|
||||
---
|
||||
|
||||
# <Title>
|
||||
@@ -60,6 +74,7 @@ created: YYYY-MM-DD
|
||||
## Approach
|
||||
## Alternatives considered
|
||||
## Constraints and risks
|
||||
## Quality bar
|
||||
## Open questions
|
||||
## Task breakdown
|
||||
|
||||
@@ -67,6 +82,8 @@ created: YYYY-MM-DD
|
||||
|---|------|------|-----------|-------|
|
||||
```
|
||||
|
||||
`## Quality bar` records the quality-bar round in human-readable form: the rigor line with its one-line reason, the surfaces line, the dropped-practices list (each entry with its one-line reason and the date it was decided), and the long-tail criteria block (`none`, or the distilled checklist for each `other:<label>` surface).
|
||||
|
||||
The plan is the implementers' entire context. Write for the "sealed container" standard: every question an implementer will hit is either answered inline or delegated via a pointer to the exact file/doc that answers it (where infra code goes, what DB tech, which layout to mirror, exact test commands). Paste short code snippets for load-bearing patterns — a path alone forces re-exploration; a stale claim fails the executor mid-implementation.
|
||||
|
||||
## Anti-patterns
|
||||
@@ -77,3 +94,4 @@ The plan is the implementers' entire context. Write for the "sealed container" s
|
||||
- Acceptance criteria describing implementation ("uses a for loop") instead of behavior.
|
||||
- Open questions the code could have answered — grep first, ask last.
|
||||
- Unrecorded decisions — every settled fork carries its reason.
|
||||
- Declaring `rigor: poc` to dodge review findings the user never agreed to drop.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
description: Review the infrastructure-as-code surface of a change - Terraform, Helm charts, Kubernetes manifests, Dockerfiles, and compose files. Checks provider/module/base-image pinning, plaintext secret material, IAM/RBAC scoping, resource requests/limits, mutable image tags in deploy paths, and destructive plan operations without lifecycle guards. Load when a diff touches *.tf files, Helm charts, K8s manifests, Dockerfiles, or compose files. Findings fold into the standard code-review severity taxonomy. Grants read-only filesystem access for tracing modules, values files, and manifests.
|
||||
enabled_tools: fs_read, fs_grep, fs_glob, fs_cat, fs_ls
|
||||
---
|
||||
You are reviewing infrastructure-as-code. The generic correctness checklist asks "does this config apply cleanly?"; you ask **"what does this change do to the running system on apply day — and on every rebuild after?"** IaC is executable: an unpinned module resolves differently next month, a wildcard grant is a standing invitation, and a resource replacement that looked like an update deletes a database. Most IaC incidents are not syntax errors — they are a drifted dependency, a `latest` tag that moved, or a destroy the plan output showed and nobody read.
|
||||
|
||||
## When to load this skill
|
||||
|
||||
The diff touches ANY of: `*.tf` files or Terraform modules, Helm charts or values files, Kubernetes manifests, Dockerfiles, or compose files. If the diff is application code with unchanged infrastructure — unload; this checklist has nothing for you.
|
||||
|
||||
## Marker semantics
|
||||
|
||||
Every checklist item below carries a severity emoji AND a `[convention]` or `[correctness]` marker; both ride in the finding title so downstream tooling can act on them mechanically. `[convention]` findings are rigor-foldable (the orchestrator may lower them under a relaxed quality bar) and rejectable — but ONLY with cited evidence: a repo convention at file:line, or a recorded plan decision. `[correctness]` is reserved for contract breaks; those findings are neither foldable nor rejectable.
|
||||
|
||||
## Linters and mechanized checks
|
||||
|
||||
The review orchestrator runs the domain's mechanized checkers — `tflint`/`checkov` for Terraform, `hadolint` for Dockerfiles, `kubeconform` for Kubernetes manifests; your CONTEXT may already include their output — do not re-derive it. Spend your prose on what those tools cannot reach: blast radius of a destructive operation, whether a wildcard grant had a scoped alternative, whether a pin was omitted deliberately. If the repo plausibly warrants a linter config it lacks (Terraform but no `tflint`/`checkov` config, Dockerfiles but no `hadolint` config, manifests but no `kubeconform` wiring), emit a 🟢 `[convention]` finding naming the gap.
|
||||
|
||||
## The checklist
|
||||
|
||||
Severities below are the production bar. Each item is a context-sensitive question, not an absolute — read the module sources, values files, and sibling stacks before flagging, and state any exemption you rely on.
|
||||
|
||||
### 1. 🟡 `[convention]` Unpinned providers, modules, or base images
|
||||
|
||||
Does the diff add or modify a provider requirement, module source, or base image without pinning it to an exact version (or digest)? Unpinned means unbuildable-reproducibly: the same code produces different infrastructure next month. Check for version constraints on providers, ref/version on module sources, and tags-plus-digests on base images. A floating constraint that the repo's lockfile then pins is a weaker finding — cite the lockfile if it exists. Internal modules versioned by the same repo's release process can be exempt; say so.
|
||||
|
||||
### 2. 🔴 `[convention]` Plaintext secret material in code, state, or values
|
||||
|
||||
Does the diff introduce secret material — passwords, tokens, keys, connection strings with credentials — in plaintext in config files, values files, environment blocks, or anywhere it lands in state or the image? Report the finding and the location; defer the exploitation analysis to `security-review`, which owns the abuse question. The severity stays 🔴 regardless of the declared quality bar — a committed secret is critical at every rigor, and this item should never be folded down. Values wired from an external secret manager, encrypted-at-rest secret stores, or CI-injected references are the correct shapes — verify the reference is actually a reference, not an inlined value.
|
||||
|
||||
### 3. 🟡 `[convention]` Wildcard IAM/RBAC where a scoped grant is available
|
||||
|
||||
Does the diff grant `*` actions, `*` resources, cluster-admin, or a similarly broad role where the workload's actual needs are enumerable? The finding must name the scoped alternative: the specific actions the code paths use, the resource ARNs/namespaces in play. A genuinely dynamic resource set can justify a partial wildcard — the finding is a wildcard chosen for convenience when a scoped grant was available. Whether the over-grant is *exploitable* in this environment is `security-review`'s question; yours is the least-privilege contract.
|
||||
|
||||
### 4. 🟢 `[convention]` Missing resource requests/limits on workloads
|
||||
|
||||
Do new or modified workloads (Deployments, StatefulSets, Jobs, compose services in deploy paths) declare resource requests and limits? A workload with no requests schedules blind and a workload with no limits can starve its node neighbors. Check whether the repo sets these via a shared chart/library or namespace defaults (LimitRange) before flagging — a house mechanism that already applies them is an exemption worth citing.
|
||||
|
||||
### 5. 🟡 `[convention]` Mutable image tags in deploy paths
|
||||
|
||||
Does anything in a deploy path reference an image by a mutable tag — `latest`, a branch name, an unversioned tag? A mutable tag means the deployed artifact changes without a corresponding code change: rollbacks stop meaning anything and two environments running "the same tag" can run different code. The fix is an immutable version tag or digest. Local-development compose files not used for deployment are exempt — verify which one this file is before flagging, and say so.
|
||||
|
||||
### 6. 🟡 `[convention]` Destructive plan operations without lifecycle guards
|
||||
|
||||
Will applying this diff destroy or replace stateful resources — a changed identifier forcing replacement, a removed resource holding data, a rename the tool treats as destroy-and-create? For resources where destruction means data loss (databases, buckets, volumes), look for the guardrails: `prevent_destroy` lifecycle blocks, deletion protection flags, `moved`/state-migration blocks for renames. The finding names the resource, why the plan will destroy it, and the guard or migration that is missing. Stateless, freely recreatable resources are exempt.
|
||||
|
||||
## Ground-truth discipline
|
||||
|
||||
- READ the module source and values files a manifest consumes, not just the diff hunk — pins, secrets, and defaults often live one level up or down from the change.
|
||||
- `fs_grep` sibling stacks/charts for the house idioms (version-pinning style, secret-reference mechanism, shared resource-limit templates) and cite the sibling at file:line when flagging deviation.
|
||||
- Distinguish deploy-path files from local-dev scaffolding before applying deploy-path severities; the file's consumers, not its syntax, determine which it is.
|
||||
- Do not guess what a plan will do from the diff alone when the change is ambiguous — say what evidence would settle it (the plan output) and flag the ambiguity itself.
|
||||
|
||||
## What this skill does NOT check
|
||||
|
||||
- Whether an exposed secret, over-grant, or open ingress is actually exploitable, and supply-chain trust of images/modules → `security-review` (this skill reports the presence of the hazard; that skill owns the abuse analysis).
|
||||
- Whether provisioning/deployment scripts mutate state idempotently and survive reruns → `transactional-integrity`.
|
||||
- Log configuration conventions inside deployed workloads → `logging-discipline`.
|
||||
- Metrics, alerts, and dashboards for new infrastructure → `observability-review`.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
description: Review the public-API surface contract of a library change - semver discipline against the manifest version, panic reachability from public entry points, doc coverage, error-type information quality, dependency weight/pinning, and internal-type leakage. Load when a diff touches the public API of a lib crate/package - exported symbols, pub items, __init__/index exports - or its manifest version. Findings fold into the standard code-review severity taxonomy. Grants read-only filesystem access for tracing exports, manifests, and public signatures.
|
||||
enabled_tools: fs_read, fs_grep, fs_glob, fs_cat, fs_ls
|
||||
---
|
||||
You are reviewing a library's public surface. The generic correctness checklist asks "does this code work?"; you ask **"what did downstream consumers just inherit?"** A library's public API is a versioned contract: every exported symbol, signature, error type, and transitive dependency becomes someone else's problem the moment it ships. Most library pain downstream is not broken logic — it is a silent semver break, a panic escaping an API that promised a `Result`, or a private type welded into a public signature that can now never change.
|
||||
|
||||
## When to load this skill
|
||||
|
||||
The diff touches ANY of: the public API of a lib crate/package — exported symbols, `pub` items, `__init__`/index exports, re-export lists — or its manifest version (Cargo.toml, package.json, pyproject.toml). If the diff is purely private internals with no public-surface or manifest change — unload; this checklist has nothing for you.
|
||||
|
||||
## Marker semantics
|
||||
|
||||
Every checklist item below carries a severity emoji AND a `[convention]` or `[correctness]` marker; both ride in the finding title so downstream tooling can act on them mechanically. `[convention]` findings are rigor-foldable (the orchestrator may lower them under a relaxed quality bar) and rejectable — but ONLY with cited evidence: a repo convention at file:line, or a recorded plan decision. `[correctness]` is reserved for contract breaks; those findings are neither foldable nor rejectable.
|
||||
|
||||
## Linters and mechanized checks
|
||||
|
||||
The review orchestrator runs mechanized checks (API-diff/semver checkers, doc-coverage lints); your CONTEXT may already include their output — do not re-derive it. Spend your prose on what linters cannot reach: whether a behavioral change breaks callers even though signatures held, whether an error type actually tells the caller what to do, whether a new dependency is worth its weight. If the repo plausibly warrants a linter config it lacks (a published library with no API-breakage check or doc lint configured), emit a 🟢 `[convention]` finding naming the gap.
|
||||
|
||||
## The checklist
|
||||
|
||||
Severities below are the production bar. Each item is a context-sensitive question, not an absolute — establish what is actually public and actually published before flagging.
|
||||
|
||||
### 1. 🔴 `[correctness]` Breaking public-API change without a major-version note
|
||||
|
||||
Does the diff remove, rename, or change the signature/behavior of anything exported — or tighten what an input accepts, or change what an error variant means? Compare against the manifest version: a breaking change is a 🔴 `[correctness]` finding unless the diff carries a major-version bump or an explicit note that one is planned for the release. Verify "published" first: symbols added earlier on this same unreleased branch are fair game to change freely, and a 0.x line may follow a different compatibility policy — read the repo's versioning statement before firing. Semver is the contract; this is never foldable and never rejectable.
|
||||
|
||||
### 2. 🟡 `[convention]` Panic/unwrap reachable from public API on user input
|
||||
|
||||
Trace new public entry points: can caller-supplied input reach a panic — `unwrap`/`expect` on values derived from arguments, unchecked indexing/slicing, unchecked arithmetic, assertions on caller data? A library that panics on bad input takes down the host application; the contract is to return the error type instead. Panics on programmer error (violated documented invariants) or in internal-only paths that input cannot reach are exempt — say so when you rely on that distinction.
|
||||
|
||||
### 3. 🟢 `[convention]` Public items with no doc comments
|
||||
|
||||
Does every new public item — function, type, trait/interface, module, re-export — carry a doc comment saying what it does, what its parameters mean, and what errors/panics it can produce? Match the repo's documentation register: in a library where every existing public item is documented, an undocumented newcomer is a clear finding; cite a documented sibling at file:line.
|
||||
|
||||
### 4. 🟡 `[convention]` Error types erasing caller-actionable information
|
||||
|
||||
Read the error paths crossing the public boundary: does the error type let a caller distinguish the cases they would handle differently — retry vs give up, bad input vs internal failure, which resource was missing? Stringly-typed errors, a single opaque variant swallowing distinct causes, and lossy conversions that drop the source error are the finding. The caller cannot match on a message string; they need variants, codes, or a source chain.
|
||||
|
||||
### 5. 🟢 `[convention]` Heavyweight or unpinned new required dependency
|
||||
|
||||
Does the diff add a required dependency? For a library, every required dependency lands in every consumer's tree: is it proportionate to what it is used for (a large framework pulled in for one helper is the finding), is its version constraint sane per the ecosystem's norm (a wildcard or unbounded range is the finding), and could it be optional/feature-gated instead? Dev/test-only dependencies are exempt. Whether the dependency is *trustworthy* (typosquats, abandonment, supply-chain risk) is `security-review`'s question.
|
||||
|
||||
### 6. 🟢 `[convention]` Internal types leaking through the public surface
|
||||
|
||||
Do new public signatures expose types that were meant to stay internal — a private module's struct now returned publicly, a third-party type welded into a public signature (locking the dependency into the public contract), or an implementation detail that consumers will now depend on? Once shipped, these can only be removed by a major version. Look for the repo's existing pattern (newtype wrappers, re-export boundaries, facade modules) and cite it when flagging.
|
||||
|
||||
## Ground-truth discipline
|
||||
|
||||
- Establish the actual public surface first: `fs_grep` the export list / re-exports / visibility modifiers — an item can be `pub` yet unreachable from outside, or private yet re-exported.
|
||||
- READ the manifest for the current version and any versioning policy notes before calling anything a semver break.
|
||||
- Check a documented, well-shaped sibling API for the house style (doc register, error-type shape, newtype boundaries) — deviation from a cited sibling is the strongest form of evidence.
|
||||
- Do not flag behavior-preserving refactors of private internals; the contract is the public surface.
|
||||
|
||||
## What this skill does NOT check
|
||||
|
||||
- Whether inputs are exploitable or a new dependency is malicious/compromised → `security-review` (this skill only weighs dependency size and pinning).
|
||||
- Whether stateful helpers the library exposes are idempotent, atomic, or retry-safe → `transactional-integrity`.
|
||||
- Log lines a library emits and their conventions → `logging-discipline`.
|
||||
- Metrics/alerts for the library's operational behavior → `observability-review`.
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
description: Calibrate log output to the repository's existing logging conventions before writing code, and review diffs for under- and over-logging. Detects the repo's logging register from sibling files - logger/framework, message style (capitalization, length, tense), payload vs ID-only context, level semantics, error-path convention - and matches it; falls back to stated best-judgment defaults when no convention exists. Load when writing code that touches boundaries, error paths, jobs, or state transitions, or when reviewing such a diff. Complements security-review (which owns secrets/PII in logs) and incident-prior-art (which treats deleted log lines as operational leads).
|
||||
---
|
||||
You are writing or reviewing code that logs — or that should. LLMs fail in both directions: narrating every step (noise operators must grep past) and swallowing error paths silently (invisible failures at 3am). "Correct" is repo-relative: detect the register, match it; where no register exists, apply the best-judgment defaults below.
|
||||
|
||||
## Step 0: Check for a declared policy first
|
||||
|
||||
Check the workspace instructions already in your context (`COYOTE.md`/`AGENTS.md`, a logging section in `CONTRIBUTING.md`) for a stated logging convention. A declaration beats detection — obey it and skip Step 1.
|
||||
|
||||
## Step 1: Detect the register (during reads you already do)
|
||||
|
||||
Pattern-matching discipline already has you reading 2-3 sibling files before writing. While reading, note how THEY log:
|
||||
|
||||
1. **Logger and shape** — which logging library/facade, and is output structured (key-value fields) or printf-style interpolated strings? Never introduce a second logging mechanism alongside an established one.
|
||||
2. **Message style** — capitalization (lowercase `"failed to connect"` vs sentence-case `"Failed to connect"`), punctuation (trailing periods or not), length (terse fragments vs full sentences), tense/mood ("connecting" / "connected" / "connect failed"). Match all of it — mixed message styles make logs harder to grep.
|
||||
3. **Context convention** — what rides along with the message: full payloads, or IDs only? Which fields are customary (request/correlation ID, entity IDs, durations)? Attached as structured fields or interpolated into the string? If the repo logs IDs-only, do NOT log payloads — that's both a style break and a data-exposure risk.
|
||||
4. **Level semantics in practice** — what does this repo actually use `error`/`warn`/`info`/`debug` for? Match observed usage over textbook definitions.
|
||||
5. **Error-path convention** — do errors get logged where they occur and then propagated, or propagated silently and logged once at the top? Match it; this determines where YOUR log lines go.
|
||||
|
||||
Sample from the same language and layer you're editing — a chatty CLI layer and a quiet library core can coexist in one repo; the nearest siblings win.
|
||||
|
||||
## Step 2: When to log (and when not)
|
||||
|
||||
Warranted — a reader on-call should be able to see:
|
||||
|
||||
- **Boundaries**: calls to external systems (network, DB, queues) — at minimum their failures, with enough context to identify the failing operation.
|
||||
- **Error paths**: every error is either logged or propagated to something that logs it — never silently swallowed, and **never both** (see invariants).
|
||||
- **Lifecycle**: job/worker/process start, finish, and abnormal exit; consumed/produced messages where the repo's register does so.
|
||||
- **State transitions an operator would care about** (order of magnitude: status changes, retries exhausted, fallbacks engaged).
|
||||
|
||||
Unwarranted:
|
||||
|
||||
- **Narration** — logging what the next line of code plainly does ("entering function", "about to save"). The comment-discipline rule, applied to logs.
|
||||
- **Hot paths** — per-item logging inside loops or per-request debug logging in high-volume paths; aggregate or sample instead.
|
||||
- **Log-and-rethrow** — logging an error AND re-raising it to a caller that logs again produces duplicate stacks that make incidents harder to read, not easier.
|
||||
- **Payloads the register doesn't log** — and never full payloads containing credentials or personal data regardless of register (security-review owns that judgment; don't create the finding).
|
||||
|
||||
## Best-judgment defaults (weak or no signal)
|
||||
|
||||
A greenfield file, a repo with no discernible convention, or contradictory siblings — use these and note the choice:
|
||||
|
||||
- Structured logging if the ecosystem's standard library or dominant framework supports it; otherwise the language's idiomatic default.
|
||||
- Terse, lowercase, no trailing period, present-tense messages ("failed to fetch invoice"), stable wording (log messages are grepped and alerted on — treat them as identifiers, not prose).
|
||||
- IDs and small scalar fields, never payloads.
|
||||
- `error` = someone may need to act, `warn` = degraded but coping, `info` = lifecycle, `debug` = development detail.
|
||||
- When genuinely unsure whether a line earns its keep: boundaries and error paths yes, everything else no.
|
||||
|
||||
## Review-side checks (for diffs)
|
||||
|
||||
- **Underdone**: a new external call, error path, or background job with zero failure visibility — no log, no metric, no propagation to a logging caller. Cite the path and what an operator would be blind to.
|
||||
- **Overdone**: narration logs, log-and-rethrow duplication, hot-loop logging, payload logging in an IDs-only repo. Cite the line and the register evidence.
|
||||
- **Register mismatch**: new log lines that break the detected message style or use a different logger/mechanism than the siblings.
|
||||
- **Deleted or reworded log lines**: operators and alerts grep for exact strings; flag deletions/rewordings of lines that look triage-relevant so the change is conscious, not accidental (incident-prior-art treats these as leads — same instinct at review time).
|
||||
- Severity calibration: silent new failure paths are 🟡 findings; style/register mismatches are 🟢/💡.
|
||||
|
||||
## Invariants (register-independent)
|
||||
|
||||
1. **No error silently swallowed.** An empty catch/ignored error with no log, no metric, and no propagation is a finding in every repo.
|
||||
2. **No double-logging of one error** along a single propagation path — one log per failure, at the level the repo's convention chooses.
|
||||
3. **No secrets or personal data in logs**, ever, regardless of how payload-happy the register is.
|
||||
4. **Never delete existing log lines as drive-by "cleanup"** — that's out-of-scope churn AND an operational hazard; if a line must go, say so explicitly in the change description.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Importing your favorite logging style into a repo that has one.
|
||||
- Logging every function entry/exit because "more visibility is better" — noise is the enemy of visibility.
|
||||
- Flagging a quiet pure-computation module for "missing logs" — the trigger surface is boundaries, error paths, jobs, and state transitions; inert code needs none.
|
||||
- Rewording existing log messages to be "cleaner" — you just broke someone's saved Loki/CloudWatch query.
|
||||
- Treating textbook level definitions as authoritative over the repo's observed usage.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
description: Review database schema migrations - expand/contract compatibility with currently-running code, reversibility, online/concurrent index creation, backfills mixed into DDL transactions, and down-migrations. Load when a diff touches migration directories/files, schema definitions, or ORM model changes. Findings fold into the standard code-review severity taxonomy. Grants read-only filesystem access for tracing migrations, schema definitions, and the code that reads the affected tables.
|
||||
enabled_tools: fs_read, fs_grep, fs_glob, fs_cat, fs_ls
|
||||
---
|
||||
You are reviewing a schema migration. The generic correctness checklist asks "does this migration apply?"; you ask **"what happens in the window when this schema and the currently-running code coexist — and what happens if we have to go back?"** A migration does not run against an idle system: for the minutes (or hours) of a rolling deploy, old code runs against the new schema, and a rollback runs old code against it indefinitely. Most migration incidents are not failed DDL — they are a column dropped while running code still reads it, a lock held on a hot table during business hours, or a bad deploy with no way back.
|
||||
|
||||
## When to load this skill
|
||||
|
||||
The diff touches ANY of: migration directories or files, schema definition files, or ORM model changes that generate schema changes. If the diff is query/application logic against an unchanged schema — unload; this checklist has nothing for you.
|
||||
|
||||
## Marker semantics
|
||||
|
||||
Every checklist item below carries a severity emoji AND a `[convention]` or `[correctness]` marker; both ride in the finding title so downstream tooling can act on them mechanically. `[convention]` findings are rigor-foldable (the orchestrator may lower them under a relaxed quality bar) and rejectable — but ONLY with cited evidence: a repo convention at file:line, or a recorded plan decision. `[correctness]` is reserved for contract breaks; those findings are neither foldable nor rejectable.
|
||||
|
||||
## Linters and mechanized checks
|
||||
|
||||
The review orchestrator runs mechanized checks (migration linters, schema-diff tools, lock-analysis checkers); your CONTEXT may already include their output — do not re-derive it. Spend your prose on what those tools cannot reach: whether the currently-running code still depends on what this migration removes, whether irreversibility was a decision or an accident, whether a table is big enough for lock duration to matter. If the repo plausibly warrants a linter config it lacks (a migration directory but no migration linter or schema-diff check configured), emit a 🟢 `[convention]` finding naming the gap.
|
||||
|
||||
## The checklist
|
||||
|
||||
Severities below are the production bar. Each item is a context-sensitive question, not an absolute — read the code that touches the affected tables and the repo's deploy story before flagging, and state any exemption you rely on.
|
||||
|
||||
### 1. 🔴 `[correctness]` Expand/contract violation against currently-running code
|
||||
|
||||
Does this migration remove or rename a column/table, tighten a constraint, or change a type that the CURRENTLY-DEPLOYED code still reads or writes? During a rolling deploy — and after any rollback — that code runs against this schema, and the violation is an outage, not a style issue. The safe sequence is expand/contract: additive schema change first, code migrated in a separate deploy, contraction only after no running code references the old shape. `fs_grep` the codebase for references to everything this migration drops or renames; a rename must land as add-new/backfill/drop-old across deploys, not as a single in-place rename. This is a contract break with the running system: never foldable, never rejectable. The exemption is genuine confirmation that nothing running references the old shape — a column already unreferenced for several releases, or a pre-first-deploy table; cite the evidence when you rely on it.
|
||||
|
||||
### 2. 🟡 `[convention]` Irreversible migration without an explicit stated reason
|
||||
|
||||
Does the migration destroy information — dropping a column with data, lossy type narrowing, collapsing values — such that no down-migration could restore it? Irreversible is sometimes the right call, but it must be a *stated* decision: a comment in the migration or an equivalent recorded note saying what is lost and why that is acceptable. Silent irreversibility is the finding; the reviewer after an incident should not have to discover it from the diff.
|
||||
|
||||
### 3. 🟡 `[convention]` Index creation without concurrent/online mode on large tables
|
||||
|
||||
Does the migration create an index on a table that is large or hot in production? Default index builds take locks that block writes for the duration of the build — on a big table that is a self-inflicted outage. Look for the engine's online path (concurrent/online index creation, e.g. `CREATE INDEX CONCURRENTLY` in Postgres, `ALGORITHM=INPLACE` in MySQL) and note that concurrent builds often cannot run inside a transaction — the migration tool may need its transaction wrapper disabled for that step. A genuinely small, cold, or brand-new table is exempt — say which and why.
|
||||
|
||||
### 4. 🟡 `[convention]` Data backfill in the same transaction as DDL
|
||||
|
||||
Does the migration mix a data backfill (UPDATE/INSERT over existing rows) into the same transaction as schema changes? A backfill over a large table holds the DDL's locks for the whole rewrite, blocks concurrent writes, and can bloat/timeout the transaction. The safe shape is: schema change in the migration, backfill as a separate batched step (separate migration, background job, or chunked script). A backfill over a provably tiny table can be exempt — state the size reasoning. How the backfill itself behaves under interruption and rerun is `transactional-integrity`'s question.
|
||||
|
||||
### 5. 🟢 `[convention]` Missing down-migration where the tool supports it
|
||||
|
||||
Does the migration tool in this repo support down/rollback scripts, and do sibling migrations provide them? Then a new migration without one is the finding — the first schema rollback should not be authored during the incident that needs it. Where the down-path is genuinely impossible (see item 2), the down script should say so explicitly rather than be omitted. Repos whose tooling or stated convention is forward-only are exempt; cite the convention.
|
||||
|
||||
## Ground-truth discipline
|
||||
|
||||
- `fs_grep` the application code for every column, table, and constraint this migration touches — the expand/contract question is answered by the code, not by the migration file.
|
||||
- READ sibling migrations for the house idioms (down-scripts, concurrent-index flags, backfill separation, naming) and cite a sibling at file:line when flagging deviation.
|
||||
- Check the migration tool's config for transaction-wrapping behavior before reasoning about what runs atomically — tools differ, and per-migration overrides matter.
|
||||
- Reason about table size honestly: if you cannot tell whether a table is large, say so and frame the finding conditionally rather than asserting an outage.
|
||||
|
||||
## What this skill does NOT check
|
||||
|
||||
- Whether backfill or migration-adjacent application code is idempotent, atomic, and safe under rerun/crash → `transactional-integrity`.
|
||||
- Whether schema changes expose sensitive data or weaken access controls in exploitable ways → `security-review`.
|
||||
- Log output of migration runs and its conventions → `logging-discipline`.
|
||||
- Metrics/alerts for migration execution and post-migration health → `observability-review`.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
description: Post-implementation observability analysis - decide what monitoring, metrics, and alerts the just-implemented code needs, account for what already exists, and either produce concrete alert-as-code changes (routed through the normal implementation pipeline) or a structured recommendations block for the final report / PR description. Advisory by design - it always produces its artifact, never a blocking verdict. Load after implementing changes that add operational surface - new external endpoints, error paths, queues/jobs/crons, or notable state machines. Grants read-only filesystem access for stack detection and coverage inventory.
|
||||
enabled_tools: fs_read, fs_grep, fs_glob, fs_cat, fs_ls
|
||||
---
|
||||
Code was just implemented; you are deciding how anyone will know when it breaks. Logging (see `logging-discipline`) makes failures *inspectable*; this pass makes them *noticed* — metrics, alerts, dashboards. The output is always an artifact (code changes or a recommendations block), never a verdict: observability judgments (thresholds, paging severity) are ultimately human calls, so this lane informs and proposes rather than blocks.
|
||||
|
||||
## When this pass applies
|
||||
|
||||
The change adds **operational surface**: a new or changed external endpoint, a new error path or failure mode, a new queue consumer/producer, background job, or cron, a new external dependency, a notable state machine, or new metrics. If none of these — pure refactor, UI polish, docs, tests — skip with a one-line note. An observability pass on inert code is budget spent producing nothing.
|
||||
|
||||
## Step 1: Detect the observability stack
|
||||
|
||||
Establish what this repo HAS before proposing anything:
|
||||
|
||||
1. **Metrics emission** — grep for the instrumentation the codebase already uses (a metrics client, OpenTelemetry, statsd-style calls, framework middleware). Note the naming convention of existing metrics.
|
||||
2. **Alert-as-code** — look for alert/monitor definitions living in the repo: rule files (e.g. Prometheus-style `*.rules.y*ml`), monitor/alert resources in infrastructure-as-code, `alerts/`/`monitoring/` directories, dashboard-as-code. THIS determines your output mode (Step 3).
|
||||
3. **Existing coverage inventory** — for the paths the change touches, find what already watches them: grep rule files and dashboards for the relevant metric names, service names, and log strings. An alert that already covers the new failure mode means UPDATE or NOTHING, not a duplicate.
|
||||
4. **Live-lookup hook (optional)** — if the caller configured an agent that can query the live monitoring stack, spawn it to verify the inventory ("what alerts currently cover <service/path>? current thresholds?") instead of trusting repo greps alone. The spawn prompt is that agent's whole context: name the services, metrics, and symptoms to look up, and state that it is read-only reconnaissance. If no such agent is configured, note that the inventory is repo-derived.
|
||||
|
||||
## Step 2: Gap analysis
|
||||
|
||||
For each new failure mode / operational surface in the change, walk the chain:
|
||||
|
||||
1. **Is there a signal?** Does anything (metric, log line, built-in framework metric) even record this failing? No signal → no alert can exist; the first recommendation is the signal itself.
|
||||
2. **Is there detection on the signal?** An existing alert/monitor that would fire? Check semantics, not just existence — an endpoint-level 5xx alert may already cover your new handler; a queue-depth alert may NOT cover your new consumer's silent skip path.
|
||||
3. **Is the detection actionable?** Would it fire with enough context to triage (labels, runbook link), at the right urgency?
|
||||
|
||||
Classify each gap: **covered** (existing signal + alert suffice), **update** (existing alert needs a label/threshold/scope change), **new** (nothing watches this), or **accepted-blind** (deliberately unwatched — say why, e.g. dev-only tooling).
|
||||
|
||||
## Step 3: Produce the artifact (write vs recommend)
|
||||
|
||||
The repo's alert-as-code situation decides:
|
||||
|
||||
- **Alert-as-code lives in this repo** and the gap warrants coverage → produce the concrete rule/monitor changes (new rules, updated thresholds/labels/scopes) as ordinary code changes, following the existing rule files' conventions exactly. Route them through the caller's NORMAL implementation pipeline — same review gates as any code. An unreviewed alert is a false-page generator.
|
||||
- **Alerting lives outside the repo** (a UI-managed system, another team's repo), or the decision is judgment-heavy (paging severity, threshold without baseline data) → produce a structured **recommendations block** for the final report / PR description instead. Never attempt to modify external systems.
|
||||
|
||||
Mixed outcomes are normal: write the mechanical rule update, recommend the judgment-heavy new pager.
|
||||
|
||||
## Output format
|
||||
|
||||
Always end with this block (it is the artifact the caller attaches to the report/PR):
|
||||
|
||||
```
|
||||
## Observability
|
||||
|
||||
Surface analyzed: <the operational surface this change adds, one line>
|
||||
Stack: <metrics lib / alert-as-code location or "external-only" / live inventory used: yes|no>
|
||||
|
||||
Covered:
|
||||
- <failure mode> — covered by <existing alert/metric, path or name>
|
||||
|
||||
Changes made (via the implementation pipeline):
|
||||
- <rule file:change> — <what and why> (or "none")
|
||||
|
||||
Recommendations (for humans to action):
|
||||
- <proposed alert> — signal: <metric/log>, condition: <threshold + rationale or "needs baseline data - start with X and tune">, urgency: <page|ticket>, runbook note: <one line>
|
||||
- <proposed metric/dashboard addition> — <why anyone would look at it>
|
||||
|
||||
Accepted blind spots:
|
||||
- <what is deliberately unwatched and why> (or "none")
|
||||
```
|
||||
|
||||
For inapplicable changes the whole block collapses to: `## Observability` / `Not applicable: <one line>`.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Alert spam.** Every alert costs attention forever. Page-worthy = a human must act NOW; everything else is a ticket or a dashboard. When in doubt, recommend ticket-urgency and say so.
|
||||
- **Invented thresholds.** A threshold with no baseline is a guess; either ground it in observed data (existing dashboards, load expectations stated in the change) or mark it explicitly as "start here, tune after N days".
|
||||
- **Duplicating existing coverage** because you only grepped for one spelling of the metric — inventory first, propose second.
|
||||
- **Metrics nobody will chart.** Each proposed metric names who would look at it and when. "Might be useful" is not a consumer.
|
||||
- **Blocking on this pass.** It is advisory: produce the artifact, attach it, move on. The only failure mode is skipping the pass on a change that added operational surface.
|
||||
- **Touching external alerting systems.** Recommendations only; live systems belong to humans and their change control.
|
||||
@@ -26,14 +26,17 @@ Walk EVERY category. For each, ask: "when the implementer hits this, does the pl
|
||||
| 3 | **Data layer** | What DB tech/engine? What migration tool and directory? What naming conventions for tables/columns? Which existing tables does this touch or reference? |
|
||||
| 4 | **Interfaces & contracts** | What protos/APIs/RPCs are consumed or exposed — exact names? Where do proto definitions live and how are they regenerated? What downstream consumers depend on the shapes this plan creates? |
|
||||
| 5 | **Conventions & tooling** | Which language/framework versions? Error-handling and logging patterns — which file shows the canon? Lint/format/build commands? Where is the repo's own CLAUDE.md / contributor doc and does the plan tell the implementer to read it? |
|
||||
| 6 | **Testing & verification** | Test framework and directory conventions? EXACT commands to run tests/build from the repo root? What proves each acceptance criterion? |
|
||||
| 6 | **Testing & verification** | Test framework and directory conventions? EXACT commands to run tests/build from the repo root? What proves each acceptance criterion? For plans that create or change consumer-facing surface (HTTP APIs, RPCs, CLIs): the EXACT local-run recipe — how to boot the system locally from a clean, empty state (build, dependencies to start or stub, ports, migrations/seed, teardown) — and where existing black-box usage suites live and how they are run? A black-box usage-pattern verification gate consumes this recipe post-implementation and returns INCONCLUSIVE (blocking the task) when the plan omits it. |
|
||||
| 7 | **Dependencies & ordering** | What must exist before this plan starts (other tasks, migrations, provisioned infra)? What does this plan produce that later work depends on? |
|
||||
| 8 | **Config, secrets & environments** | New env vars/config keys — where are they declared and injected? Secrets — vault/parameter store conventions? Staging vs production differences that affect implementation? |
|
||||
| 9 | **Scope boundaries** | Is Out of scope present and specific? Are "tempting adjacent fixes" explicitly deferred? |
|
||||
| 10 | **Settled decisions** | Are choices that were debated recorded WITH their one-line reason ("RDS over in-cluster Postgres because ops owns backups")? An unrecorded decision WILL be re-litigated by the implementer. |
|
||||
| 11 | **Quality bar** | Are `rigor` and `surfaces` declared (or the plan explicitly generic)? Is every dropped best practice recorded WITH a one-line reason? Does every `other:<label>` surface carry non-empty long-tail criteria? |
|
||||
|
||||
Not every category applies to every plan (a docs-only plan has no data layer). Mark inapplicable categories as such — silently skipping one is how leaks survive.
|
||||
|
||||
For category 11 specifically: a missing Quality bar declaration is FRICTION when the plan is plausibly `surfaces: []` (a docs-only or pure-refactor plan), BLOCKING when the plan self-evidently builds an enum surface (an HTTP API, a CLI, migrations…) but declares none.
|
||||
|
||||
## Pointer verification (do not trust, verify)
|
||||
|
||||
For every pointer the plan offers:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
description: Review the API surface contract of a change - REST/HTTP routes and handlers, request/response types, OpenAPI specs, plus gRPC services and GraphQL schemas/resolvers when the diff is that flavor. Checks pagination, recorded auth decisions, HTTP method semantics, error-shape consistency, boundary validation, and versioned contract evolution. Load when a diff touches HTTP route/handler definitions, request/response types, OpenAPI specs, proto files, or GraphQL schemas/resolvers. Findings fold into the standard code-review severity taxonomy. Grants read-only filesystem access for tracing routes, types, and published contracts.
|
||||
enabled_tools: fs_read, fs_grep, fs_glob, fs_cat, fs_ls
|
||||
---
|
||||
You are reviewing an API surface. The generic correctness checklist asks "does this handler work?"; you ask **"does this endpoint honor the contract its callers already depend on — and did anyone record the decisions callers will need?"** An API is a promise: clients you cannot see build against the shapes, semantics, and error formats you ship. Most API pain in production is not broken logic — it is a silently changed shape, an unbounded list that grew, or an error format that differs from every sibling endpoint.
|
||||
|
||||
## When to load this skill
|
||||
|
||||
The diff touches ANY of: HTTP route or handler definitions, request/response types, OpenAPI/Swagger specs, gRPC `.proto` files or service implementations, GraphQL schemas or resolvers. If the diff is internal logic behind an unchanged API surface — unload; this checklist has nothing for you.
|
||||
|
||||
## Marker semantics
|
||||
|
||||
Every checklist item below carries a severity emoji AND a `[convention]` or `[correctness]` marker; both ride in the finding title so downstream tooling can act on them mechanically. `[convention]` findings are rigor-foldable (the orchestrator may lower them under a relaxed quality bar) and rejectable — but ONLY with cited evidence: a repo convention at file:line, or a recorded plan decision. `[correctness]` is reserved for contract breaks; those findings are neither foldable nor rejectable.
|
||||
|
||||
## Linters and mechanized checks
|
||||
|
||||
The review orchestrator runs mechanized checks (spec linters, schema diff tools, breaking-change detectors); your CONTEXT may already include their output — do not re-derive it. Spend your prose on what linters cannot reach: whether pagination is warranted, whether the auth decision was recorded, whether an evolution is actually compatible for real callers. If the repo plausibly warrants a linter config it lacks (an OpenAPI spec but no spec linter, protos but no breaking-change check), emit a 🟢 `[convention]` finding naming the gap.
|
||||
|
||||
## The checklist (REST/HTTP)
|
||||
|
||||
Severities below are the production bar. Each item is a context-sensitive question, not an absolute — read the surrounding code and the callers before flagging.
|
||||
|
||||
### 1. 🟡 `[convention]` Unbounded collection endpoint without pagination
|
||||
|
||||
Does the diff add or modify an endpoint that returns a collection? If the collection can grow without bound (rows in a table, user-generated items), missing pagination is a finding: name the endpoint, the backing query, and the growth vector. A provably bounded small set is exempt — an enum-backed list, a fixed config table, a per-user set with a hard cap — and when you rely on that exemption, say so explicitly in your notes so the next reviewer sees it was considered, not missed.
|
||||
|
||||
### 2. 🟡 `[convention]` Route without a recorded authn/z decision
|
||||
|
||||
For every new or changed route: is there a recorded decision that this route is public, authenticated, or role-gated? "Recorded" means visible in code or spec — middleware attached, an annotation, a spec `security` block, or an explicit comment for deliberately public routes. A route with no discernible decision is the finding. Whether the auth implementation is *bypassable* is not your question — that belongs to `security-review`; you only verify the decision exists and is stated.
|
||||
|
||||
### 3. 🟡 `[convention]` Non-idempotent PUT/DELETE semantics
|
||||
|
||||
PUT and DELETE carry idempotency promises by HTTP contract: repeating a PUT must converge on the same state; repeating a DELETE must not error in a way that breaks retrying clients (a second DELETE returning 404 or 204 is fine; returning 500 is not). Does the diff's handler honor the method it is mounted on — or should it be a POST? You check the *declared method semantics*; whether the state change is mechanically idempotent under retries and concurrency belongs to `transactional-integrity`.
|
||||
|
||||
### 4. 🟡/🟢 `[convention]` Error responses leaking internals or inconsistent error shape
|
||||
|
||||
Read the error paths: do responses leak internals — stack traces, SQL fragments, internal hostnames, framework default error pages (🟡)? Do they match the error shape the repo's sibling endpoints already return — same envelope, same code/message fields (🟢 when merely inconsistent)? `fs_grep` a sibling handler's error response to establish the house shape before flagging. Whether a leak is *exploitable* is `security-review`'s call; yours is the contract and consistency question.
|
||||
|
||||
### 5. 🔴 `[correctness]` Breaking a published request/response shape without versioning
|
||||
|
||||
Does the diff remove or rename a field, change a type, tighten accepted input, or change status codes on an endpoint that is already published (in a released spec, consumed by known clients, or exposed beyond this repo)? That is a contract break — a 🔴 `[correctness]` finding unless the change ships behind a new version (path version, header version, or an additive evolution that old clients tolerate). Verify "published" before firing: an endpoint added earlier in this same unreleased branch is not published, and changing it freely is fine.
|
||||
|
||||
### 6. 🟡 `[convention]` Missing boundary input validation
|
||||
|
||||
At the request boundary, is input validated at all — types enforced, required fields checked, sizes/ranges bounded — before it flows inward? Absence of any validation on a new input path is the finding. Whether unvalidated input is *exploitable* (injection, traversal) is deferred to `security-review`; you flag the missing guardrail, not the attack.
|
||||
|
||||
## gRPC section (apply when the diff touches protos or gRPC services)
|
||||
|
||||
- 🟡 `[convention]` **Deadlines** — do new client calls set deadlines, and do servers propagate the caller's deadline to their own outbound calls? A call chain with no deadline anywhere hangs forever on a stuck dependency.
|
||||
- 🟡 `[convention]` **Status-code discipline** — do handlers return meaningful gRPC status codes (`NOT_FOUND`, `INVALID_ARGUMENT`, `ALREADY_EXISTS`) rather than collapsing every failure into `UNKNOWN`/`INTERNAL`? Callers branch on these codes; a flattened code space breaks their error handling.
|
||||
- 🔴 `[correctness]` **Backwards-compatible proto evolution** — on a published proto: no field-number reuse, no type changes on existing fields, no renumbering, new fields optional with fresh numbers. Violating any of these silently corrupts data for old clients — same contract-break bar as item 5.
|
||||
- 🟢 `[convention]` **Field deprecation** — removed fields should be `reserved` (number and name) and deprecations marked with the `deprecated` option, not deleted outright, so the number can never be reused.
|
||||
|
||||
## GraphQL section (apply when the diff touches schemas or resolvers)
|
||||
|
||||
- 🟡 `[convention]` **Resolver N+1** — does a new list-field resolver fetch per-item (a query inside a loop, or a per-parent resolver hitting the DB)? Look for a dataloader/batching layer; its absence on a list path is the finding.
|
||||
- 🟡 `[convention]` **Depth/complexity limits** — if the diff grows the schema's reachable graph (new nested relations), is there a depth or complexity limit configured anywhere? An unlimited schema is a self-service DoS invitation; check the server setup before assuming.
|
||||
- 🟡 `[convention]` **Connection-style pagination** — list fields over unbounded collections should use the repo's established pagination idiom (connections/edges or equivalent). The same bounded-set exemption as REST item 1 applies — and state it when you use it. Breaking a published schema field without a deprecation cycle falls under item 5's 🔴 `[correctness]` bar.
|
||||
|
||||
## Ground-truth discipline
|
||||
|
||||
- READ the route registration and middleware chain, not just the handler — auth decisions and pagination defaults often live up-stack.
|
||||
- `fs_grep` for the spec file (OpenAPI, proto, GraphQL schema) that publishes the shape the diff changes; the spec, not the struct, is the contract.
|
||||
- Check sibling endpoints for the house error shape, pagination idiom, and auth annotation style before flagging deviation — the strongest finding cites the sibling at file:line.
|
||||
|
||||
## What this skill does NOT check
|
||||
|
||||
- Whether auth is bypassable, input is exploitable, or errors leak abusable secrets → `security-review`.
|
||||
- Whether state-changing handlers are mechanically idempotent, atomic, or retry-safe → `transactional-integrity`.
|
||||
- Log lines, levels, and message conventions in handlers → `logging-discipline`.
|
||||
- Metrics, alerts, and dashboards for new endpoints → `observability-review`.
|
||||
@@ -28,6 +28,7 @@ points: 1.0 # engineer-days; ~1.0 per the sizing rule
|
||||
plan: PLAN-<slug>.md
|
||||
blocked_by: [] # TASK ids that must be complete first
|
||||
created: YYYY-MM-DD
|
||||
surfaces: [] # this task's surfaces; omitted = inherit the plan's list
|
||||
---
|
||||
|
||||
## What
|
||||
@@ -47,6 +48,8 @@ One paragraph: what this task produces, named concretely (files, symbols, behavi
|
||||
|
||||
Status lives in frontmatter — there are no lifecycle directories. `status: complete` plus all boxes checked IS done.
|
||||
|
||||
`surfaces` come from the plan's `## Quality bar`; rigor is run-level only — there is no per-task rigor.
|
||||
|
||||
## log.md conventions
|
||||
|
||||
Append-only. Each entry is an H2: `## YYYY-MM-DD — <short label>` (`created`, `started`, `implemented`, `diverged`, `completed`, ...). Body is 1-3 sentences of prose; structured data lives in markdown links (branch URLs, commit SHAs, PR links). Never rewrite an old entry — add a new one.
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
description: Verify a change from the consumer's perspective - exercise the changed surface (HTTP API, RPC, CLI) black-box against a locally running instance with clean, isolated state. Run existing usage suites first for regressions, derive new tests from the spec (never the implementation), and classify every failure as bug / environment / expected contract change. Produces a USAGE_PROBE PASS/FAIL/INCONCLUSIVE verdict. Complements code-review (quality), adversarial-review (plan conformance), and security-review (abuse) - this is the only gate that tests BEHAVIOR by using the thing, not by reading it.
|
||||
enabled_tools: fs_read, fs_cat, fs_grep, fs_glob, fs_ls, fs_write, fs_patch, execute_command
|
||||
---
|
||||
You are verifying a change the way its consumers will experience it: by USING it. Every other
|
||||
review gate reads text — the diff, the plan, the code. This gate boots the system locally from a
|
||||
clean state and exercises the changed surface as a cold-start consumer would. It catches the class
|
||||
of defects invisible to static review: serialization quirks, replace-vs-patch semantics,
|
||||
wrong status codes, broken idempotency, empty-state crashes, auth holes on new routes.
|
||||
|
||||
## The one question
|
||||
|
||||
**Does the changed consumer-facing surface behave as the spec promises when actually used,
|
||||
starting from nothing?** You are not judging code quality, plan conformance, or exploitability —
|
||||
other gates own those. You judge observable behavior.
|
||||
|
||||
## The independence rule (spec-first, or the gate is worthless)
|
||||
|
||||
Derive expected behaviors from the **spec** — the plan/task acceptance criteria, the API contract
|
||||
(IDL/schema/OpenAPI/proto definitions), the documented CLI help — **BEFORE reading the
|
||||
implementation**. If you read the handler first and write tests that mirror it, you have re-proven
|
||||
the implementation's own assumptions, including its misreadings of the spec. Order of operations:
|
||||
|
||||
1. Read the spec + contract. Write down the expected behaviors as concrete request→response pairs.
|
||||
2. Only THEN read implementation code — and only as much as needed to find ports, config, and
|
||||
startup wiring. Never to "check what it actually does" before your expectations are written.
|
||||
|
||||
## Phase order
|
||||
|
||||
### 1. Identify the surface under test
|
||||
|
||||
From the diff (or the caller's summary): which endpoints/RPCs/commands were added or changed?
|
||||
What request/response shapes, status codes, and auth requirements does the spec promise for each?
|
||||
If the change touches no consumer-facing surface, say so and return PASS with a one-line note —
|
||||
probing inert internals burns budget without value.
|
||||
|
||||
### 2. Regression-first: find and run existing usage suites
|
||||
|
||||
Discover what already exists before authoring anything:
|
||||
|
||||
- `fs_glob` for suite files in ANY format the repo uses: `**/*.hurl`, `**/*.http`, `**/*.rest`,
|
||||
`**/*.postman_collection.json`, `**/*.bru`, `**/e2e/**`, `**/integration/**`, `**/api-test*/**`,
|
||||
`**/smoke*/**`, plus repo scripts that run them (`**/run-*test*`, Makefile/justfile targets,
|
||||
package-manifest script entries) and shell scripts of `curl` commands (`fs_grep` for `curl `
|
||||
under `scripts/`, `test/`, `tools/`).
|
||||
- Read the repo's contributor docs for the sanctioned way to run them.
|
||||
|
||||
Run the existing suites against the changed code FIRST. Every failure here is a candidate
|
||||
regression. Classify each (see § Failure classification) — a failure is only acceptable when the
|
||||
spec EXPLICITLY changed that contract, and then the old test needs updating (note it in the
|
||||
report), not ignoring.
|
||||
|
||||
### 3. Map coverage, author the delta
|
||||
|
||||
List which of your expected behaviors from step 1 the existing suites already prove. Author new
|
||||
tests ONLY for the uncovered ones. Walk this usage-pattern checklist for each changed surface —
|
||||
these are the cases implementers systematically forget:
|
||||
|
||||
| Pattern | What to probe |
|
||||
|---------|---------------|
|
||||
| Cold start / empty state | First-ever call with no pre-existing data: list → empty (not 500), get → not-found (not panic) |
|
||||
| Happy path | The spec's primary flow, end to end, asserting the full response shape — not just the status code |
|
||||
| Idempotency / re-call | Same create/update twice: duplicate error or no-op, per the spec — never silent double-write |
|
||||
| Invalid input | Missing required fields, wrong types, out-of-range values, malformed body → the spec's error shape and code, not a 500 |
|
||||
| Auth on the new surface | Missing/expired/insufficient credentials → the correct 401/403 (a new route with no auth check is a common miss) |
|
||||
| Not-found and stale references | Operations on IDs that don't exist or were deleted |
|
||||
| Partial update semantics | Does omitting a field preserve it (patch) or delete it (replace)? Assert whichever the spec promises — this is a classic silent-data-loss bug |
|
||||
| Serialization edges | Zero values, empty lists, unset optionals: encoders that omit zero values make `== false`/`== null` asserts lie — assert existence/absence per the actual encoding |
|
||||
| Pagination / limits | Page past the end, limit 0/1/max, stable ordering if promised |
|
||||
| Error shape consistency | New errors follow the same envelope as the rest of the surface |
|
||||
| State transitions | Illegal transitions rejected; legal ones observable via subsequent reads |
|
||||
|
||||
Write the new tests where the repo's existing suites live, following their naming and layout
|
||||
conventions, so they are adoptable as permanent regression tests. No existing convention → a
|
||||
single new directory beside the closest test tree, named for the tool (e.g. `tests/usage/`).
|
||||
|
||||
### 4. Environment discipline (clean, local, isolated)
|
||||
|
||||
- **Clean state is non-negotiable.** Boot from nothing: fresh/ephemeral database (throwaway
|
||||
container, tmp file, or dedicated schema), run migrations, seed ONLY what the tests create
|
||||
themselves. Tests that depend on pre-existing data are not cold-start tests.
|
||||
- **Fully local.** Stub or mock external dependencies (fake servers, recorded fixtures, in-memory
|
||||
substitutes) — a probe that calls real third-party systems is a flake generator and a hazard.
|
||||
- **Prefer the repo's own recipe.** If the plan or contributor docs provide a local-run recipe
|
||||
(compose file, make target, dev script), use it verbatim before inventing your own. If you must
|
||||
invent one, record every step in the report so it can be promoted into the docs.
|
||||
- **Teardown.** Leave no running processes, containers, or dirty state behind.
|
||||
- **Bounded startup retries only.** Retry/poll while the stack boots (bounded attempts, short
|
||||
interval). NEVER add retries to make a flaky assertion pass — flakiness on a settled stack is a
|
||||
finding.
|
||||
|
||||
### 5. Failure classification (every failure gets exactly one)
|
||||
|
||||
| Class | Meaning | Effect on verdict |
|
||||
|-------|---------|-------------------|
|
||||
| **BUG** | The running system violates the spec | FAIL — report with repro |
|
||||
| **EXPECTED-CHANGE** | An existing test asserts a contract the spec explicitly changed | Does not fail the verdict; the stale test is flagged for update |
|
||||
| **ENV** | The failure is in bringing the stack up or reaching it, not in behavior | Does not count as a bug; if it prevents meaningful probing → INCONCLUSIVE |
|
||||
|
||||
Misclassifying ENV as BUG sends the implementer chasing ghosts; misclassifying BUG as ENV ships
|
||||
the defect. When unsure, reproduce twice and read the server logs before deciding.
|
||||
|
||||
## Toolbox (repo conventions first; these are examples, not requirements)
|
||||
|
||||
No specific tool is required. Precedence: (1) whatever format/harness the repo's existing usage
|
||||
suites already use — run and extend that; (2) a well-suited tool from the examples below if it is
|
||||
available or trivially installable; (3) ubiquitous fallbacks (`curl` + shell assertions cover any
|
||||
HTTP surface). What is non-negotiable is the discipline — spec-first asserts, clean state — not
|
||||
the tool.
|
||||
|
||||
| Surface | Example tools | Notes |
|
||||
|---------|--------------|-------|
|
||||
| HTTP/REST/JSON (incl. gRPC-over-HTTP with JSON encoding) | [Hurl](https://hurl.dev) `.hurl` files; `curl` scripts | Hurl: plain-text request/assert format, capturable variables, `retry` for eventual consistency; files double as committed regression suites |
|
||||
| Pure gRPC/protobuf | `grpcurl` (scripted) | Use server reflection or point at the proto files |
|
||||
| CLI | Direct invocation via `execute_command` | Assert exit codes AND output; probe stdin/args edge cases |
|
||||
| Anything else | `curl`/scripts/the repo's own test harness | Same discipline: spec-first asserts, clean state |
|
||||
|
||||
Optional niceties like Hurl and grpcurl may already be preinstalled (e.g. by a sandbox mixin) or
|
||||
can be installed idempotently (hurl via the distro package manager first — its prebuilt GitHub
|
||||
tarball dynamically links `libxml2.so.2`, which newer distros no longer ship; grpcurl from its
|
||||
GitHub release, a static Go binary). When a preferred tool is unavailable and uninstallable, fall
|
||||
back to what exists rather than skipping the check; classify a probe as ENV only when NO adequate
|
||||
tool can exercise the surface.
|
||||
|
||||
If you use Hurl, gotchas that produce false results if unknown:
|
||||
|
||||
- `[Captures]` run BEFORE `[Asserts]` in the same entry — capture a replaced value under a NEW
|
||||
variable name, or your inequality asserts compare a value to itself.
|
||||
- JSON encoders that omit zero/empty values: assert `not exists` for absent fields — `== false`
|
||||
or `== null` asserts fail on omitted keys.
|
||||
- Use `[Options] retry` with a bounded count for asynchronous effects (job completion, eventual
|
||||
reads); never unbounded.
|
||||
|
||||
## Verdict format
|
||||
|
||||
End with EXACTLY one of:
|
||||
|
||||
```
|
||||
USAGE_PROBE: PASS
|
||||
Surface: <endpoints/RPCs/commands probed>. Existing suites: <N run, all green | none found>.
|
||||
New tests: <M authored at <path>, all green>.
|
||||
<optional: 1-3 non-blocking observations (stale tests to update, recipe gaps)>
|
||||
```
|
||||
|
||||
```
|
||||
USAGE_PROBE: FAIL
|
||||
Surface: <...>. Existing suites: <N run, X failed (Y regressions, Z expected-change)>. New tests: <M authored, W failed>.
|
||||
Behavioral findings:
|
||||
1. <surface + case> — <spec'd behavior, quoting the spec> — <observed behavior> — REPRO: <exact request/command + response received> — <test file:entry>
|
||||
Stale tests needing update (expected-change): <list or none>
|
||||
```
|
||||
|
||||
```
|
||||
USAGE_PROBE: INCONCLUSIVE
|
||||
Could not establish a clean local environment: <what failed, verbatim error>.
|
||||
Missing: <the exact recipe/fixture/mock that would unblock — phrased so the plan author can add it>.
|
||||
Partial results (if any): <what did run and what it showed>
|
||||
```
|
||||
|
||||
Every FAIL finding MUST include the exact reproduction (request/command and the response
|
||||
received) and cite the test file — a behavioral complaint without a repro is noise. INCONCLUSIVE
|
||||
is an honest, acceptable verdict: it routes the fix to the environment recipe, not the code.
|
||||
NEVER report INCONCLUSIVE as PASS ("couldn't test, probably fine") or as FAIL (the implementer
|
||||
would hunt a nonexistent bug).
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Writing tests after reading the implementation — you will encode its bugs as expectations.
|
||||
- Skipping the existing suites and jumping to new tests — regressions are the cheapest bugs to catch.
|
||||
- Testing through internal seams (direct DB reads, internal function calls) — this gate is
|
||||
consumer-perspective only; internals belong to unit tests.
|
||||
- Depending on pre-existing data, shared databases, or previously running services.
|
||||
- Adding retries/sleeps until a flaky assertion passes — flakiness is a finding, not an obstacle.
|
||||
- Reporting an environment failure as a behavioral FAIL (or burying it in a PASS).
|
||||
- Throwaway tests in /tmp — tests that don't land in the repo's suite location die with the run.
|
||||
- Asserting only status codes — shape and content are where the quirks live.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
description: Review the background-work surface contract of a change - queue/stream consumers, scheduled jobs, and cron handlers. Checks retry/backoff policy, DLQ/poison-message routing, graceful-shutdown drain, concurrency/prefetch bounds, and visibility-timeout vs processing-time reasoning. Load when a diff touches queue/job/cron consumer registration, handler wiring, or schedule definitions. Findings fold into the standard code-review severity taxonomy. Grants read-only filesystem access for tracing consumer registration, transport config, and shutdown paths.
|
||||
enabled_tools: fs_read, fs_grep, fs_glob, fs_cat, fs_ls
|
||||
---
|
||||
You are reviewing background-work wiring. The generic correctness checklist asks "does this handler process a message?"; you ask **"what happens to this consumer on the bad days — a poison message, a deploy mid-batch, a downstream outage?"** A worker's contract is with the queue, the scheduler, and the deploy pipeline, not with a single happy-path message. Most worker incidents are not broken handler logic — they are a queue wedged behind one malformed message, in-flight work silently dropped by a rolling restart, or a retry storm hammering a struggling dependency.
|
||||
|
||||
## When to load this skill
|
||||
|
||||
The diff touches ANY of: queue/stream consumer registration, job/worker handler wiring, cron or schedule definitions, or the transport configuration behind them (retry counts, prefetch, visibility timeouts, shutdown hooks). If the diff is handler business logic behind unchanged wiring — unload; this checklist has nothing for you. Note: a diff that wires a consumer AND changes state is two reviews — this skill covers the consumer contract, and `transactional-integrity` covers the state changes; state-changing worker diffs load BOTH.
|
||||
|
||||
## Marker semantics
|
||||
|
||||
Every checklist item below carries a severity emoji AND a `[convention]` or `[correctness]` marker; both ride in the finding title so downstream tooling can act on them mechanically. `[convention]` findings are rigor-foldable (the orchestrator may lower them under a relaxed quality bar) and rejectable — but ONLY with cited evidence: a repo convention at file:line, or a recorded plan decision. `[correctness]` is reserved for contract breaks; those findings are neither foldable nor rejectable.
|
||||
|
||||
## Linters and mechanized checks
|
||||
|
||||
The review orchestrator runs mechanized checks (config validators, schema checks for queue/schedule definitions); your CONTEXT may already include their output — do not re-derive it. Spend your prose on what linters cannot reach: whether a retry policy exists at all, where a poison message goes, what a deploy does to in-flight work. If the repo plausibly warrants a linter config it lacks, emit a 🟢 `[convention]` finding naming the gap.
|
||||
|
||||
## The checklist
|
||||
|
||||
Severities below are the production bar. Each item is a context-sensitive question, not an absolute — read the transport's own guarantees and the deployment story before flagging, and when you rely on an exemption, state it so the next reviewer sees it was considered.
|
||||
|
||||
### 1. 🟡 `[convention]` No retry/backoff policy where the transport provides none
|
||||
|
||||
For each new or rewired consumer: when the handler fails, who retries, how many times, with what backoff? Some transports provide redelivery with backoff out of the box — READ the transport config before claiming they do here. If neither the transport nor the code establishes a policy, a transient downstream blip becomes permanent message loss (or an immediate hot-loop of retries). Name the consumer, the transport, and what a single failure currently does. A consumer whose transport is configured with sane redelivery is exempt — cite the config at file:line.
|
||||
|
||||
### 2. 🟡 `[convention]` No DLQ/poison-message route — one bad message wedges the queue
|
||||
|
||||
A message that fails every retry must go SOMEWHERE terminal: a dead-letter queue, a parked table, a quarantine topic. Trace the exhausted-retries path for each consumer the diff adds: if the message returns to the head of the queue forever, one malformed payload halts all processing behind it. Ordered/single-partition consumers are the highest-blast-radius case. Where the message *lands* is your question; whether the handler's error path also acknowledges correctly under at-least-once delivery is `transactional-integrity`'s.
|
||||
|
||||
### 3. 🟡 `[convention]` No graceful-shutdown drain — in-flight work lost on deploy
|
||||
|
||||
Every deploy sends this worker a termination signal mid-message. Does the diff's worker stop taking new work, finish (or cleanly nack) what is in flight, and exit within the platform's grace period? Look for a shutdown hook, drain loop, or the framework's built-in drain — and check the termination grace configured for the deployment. Exemptions are real: a stateless cron job reading a read-only source loses nothing on interruption, and an at-least-once transport redelivers whatever was in flight (making drain an efficiency concern, not a loss) — say explicitly which exemption you are relying on. The item bites hardest for at-least-once consumers with long-running in-flight work and for anything ack-early.
|
||||
|
||||
### 4. 🟢 `[convention]` Unbounded concurrency/prefetch
|
||||
|
||||
Does the new consumer bound how many messages it processes at once — worker-pool size, prefetch/fetch-count, max in-flight? An unbounded consumer amplifies a queue backlog into a self-inflicted outage: memory blowup, connection-pool exhaustion, a thundering herd against the downstream the handler calls. Check the transport defaults before flagging — some default to a sane prefetch; some default to unlimited. A low-volume schedule-driven job with structurally bounded input is exempt.
|
||||
|
||||
### 5. 🟢 `[convention]` Visibility-timeout vs processing-time reasoning absent
|
||||
|
||||
Where the transport uses a visibility timeout, lease, or lock (with redelivery when it expires): is there any evidence — a comment, a config value derived from measurements, a heartbeat/extension call — that the timeout exceeds the handler's realistic worst-case processing time? A timeout shorter than processing time means the message redelivers WHILE the first attempt is still running: duplicate concurrent processing by design. You flag the absent reasoning; whether the handler survives that concurrent duplicate is `transactional-integrity`'s question. Transports with no visibility/lease mechanism are exempt.
|
||||
|
||||
## Ground-truth discipline
|
||||
|
||||
- READ the transport/framework configuration, not just the handler — retry counts, DLQ wiring, prefetch, and drain behavior live in config and registration code, not in the handler body.
|
||||
- `fs_grep` sibling consumers for the established idioms (DLQ naming, shutdown hooks, backoff helpers) — a new consumer skipping the house pattern is the strongest form of evidence; cite the sibling at file:line.
|
||||
- Check the deployment manifests for termination grace periods when reasoning about drain — the code's drain loop is only as good as the time the platform gives it.
|
||||
- Do not flag a missing guard the transport demonstrably provides; cite the config that provides it instead.
|
||||
|
||||
## What this skill does NOT check
|
||||
|
||||
- Whether the handler is idempotent under redelivery, atomic across writes, or safe against dual-writes to a DB plus an external system → `transactional-integrity` (state-changing worker diffs load both skills).
|
||||
- Whether job payloads, queue names, or worker logs expose secrets or abusable data → `security-review` and `logging-discipline`.
|
||||
- Metrics, alerts, and dashboards for the new worker (queue depth, processing lag, failure rate) → `observability-review`.
|
||||
- Log lines, levels, and message conventions inside the handler → `logging-discipline`.
|
||||
+64
-52
@@ -11,62 +11,74 @@
|
||||
# - <agent-name>_AGENT_SESSION
|
||||
# - <agent-name>_VARIABLES (as JSON array of key-value pairs; e.g. '[{"name": "username", "value": "alex"}]')
|
||||
|
||||
model: openai:gpt-4o # Specify the LLM to use
|
||||
temperature: null # Set default temperature parameter, range (0, 1)
|
||||
top_p: null # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model
|
||||
reasoning_effort: null # Reasoning effort level for models that support it (e.g. low, medium, high).
|
||||
# Only valid when the agent's model declares reasoning_levels.
|
||||
agent_session: null # Set a session to use when starting the agent. (e.g. temp, default); defaults to globally set agent_session
|
||||
name: <agent-name> # Name of the agent, used in the UI and logs
|
||||
description: <description> # Description of the agent, used in the UI
|
||||
version: 1 # Version of the agent
|
||||
model: openai:gpt-4o # Specify the LLM to use
|
||||
temperature: null # Set default temperature parameter, range (0, 1)
|
||||
top_p: null # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model
|
||||
reasoning_effort:
|
||||
null # Reasoning effort level for models that support it (e.g. low, medium, high).
|
||||
# Only valid when the agent's model declares reasoning_levels.
|
||||
agent_session: null # Set a session to use when starting the agent. (e.g. temp, default); defaults to globally set agent_session
|
||||
name: <agent-name> # Name of the agent, used in the UI and logs
|
||||
description: <description> # Description of the agent, used in the UI
|
||||
version: 1 # Version of the agent
|
||||
# Auto-Continue (Todo System)
|
||||
# The auto-continue system provides built-in task tracking for improved reliability.
|
||||
# When enabled, the model can create todo lists and the system will automatically
|
||||
# prompt it to continue when incomplete tasks remain.
|
||||
# See the [Todo System documentation](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System) for more information
|
||||
auto_continue: false # Enable automatic continuation when incomplete todos remain
|
||||
max_auto_continues: 10 # Maximum number of automatic continuations before stopping
|
||||
inject_todo_instructions: true # Inject the default todo tool usage instructions into the agent's system prompt
|
||||
continuation_prompt: null # Custom prompt used when auto-continuing (optional; uses default if null)
|
||||
auto_continue: false # Enable automatic continuation when incomplete todos remain
|
||||
max_auto_continues: 10 # Maximum number of automatic continuations before stopping
|
||||
inject_todo_instructions: true # Inject the default todo tool usage instructions into the agent's system prompt
|
||||
continuation_prompt: null # Custom prompt used when auto-continuing (optional; uses default if null)
|
||||
# Sub-Agent Spawning System
|
||||
# Enable this agent to spawn and manage child agents in parallel.
|
||||
# See https://github.com/Dark-Alex-17/coyote/wiki/Agents for detailed documentation.
|
||||
can_spawn_agents: false # Enable the agent to spawn child agents
|
||||
can_spawn_agents: false # Enable the agent to spawn child agents
|
||||
# spawnable_agents: # Optional whitelist restricting which agents can be spawned via `agent__spawn`.
|
||||
# - explore # If omitted (the default), ALL installed agents are spawnable. This is the unrestricted default.
|
||||
# - coder # Provide a list to restrict. Match is exact and case-sensitive (use directory names).
|
||||
# - oracle # An empty list ([]) means literally nothing spawnable.
|
||||
# Also filters `agent__list_available` output so the LLM only sees what it can spawn.
|
||||
# Graph agents (graph.yaml) ignore this; they declare spawn targets in agent nodes.
|
||||
max_concurrent_agents: 4 # Maximum number of agents that can run simultaneously
|
||||
max_agent_depth: 3 # Maximum nesting depth for sub-agents (prevents runaway spawning)
|
||||
inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt
|
||||
summarization_model: null # Model to use for summarizing sub-agent output (e.g. 'openai:gpt-4o-mini'); defaults to current model
|
||||
summarization_threshold: 4000 # Character threshold above which sub-agent output is summarized before returning to parent
|
||||
escalation_timeout: 300 # Seconds a sub-agent waits for a user interaction response before timing out (default: 5 minutes)
|
||||
mcp_servers: # Optional list of MCP servers that the agent utilizes
|
||||
- github # Corresponds to the name of an MCP server in the `<coyote-config-dir>/functions/mcp.json` file
|
||||
global_tools: # Optional list of additional global tools to enable for the agent; i.e. not tools specific to the agent
|
||||
# Also filters `agent__list_available` output so the LLM only sees what it can spawn.
|
||||
# Graph agents (graph.yaml) ignore this; they declare spawn targets in agent nodes.
|
||||
max_concurrent_agents: 4 # Maximum number of agents that can run simultaneously
|
||||
max_agent_depth: 3 # Maximum nesting depth for sub-agents (prevents runaway spawning)
|
||||
max_concurrent_jobs:
|
||||
5 # Max background jobs (`job__*` tools) running at once for this agent
|
||||
# (overrides the global setting; 0 disables background jobs for this agent)
|
||||
inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt
|
||||
summarization_model: null # Model to use for summarizing sub-agent output (e.g. 'openai:gpt-4o-mini'); defaults to current model
|
||||
summarization_threshold: 4000 # Character threshold above which sub-agent output is summarized before returning to parent
|
||||
escalation_timeout: 300 # Seconds a sub-agent waits for a user interaction response before timing out (default: 5 minutes)
|
||||
mcp_servers: # Optional list of MCP servers that the agent utilizes
|
||||
- github # Corresponds to the name of an MCP server in the `<coyote-config-dir>/mcp.json` file
|
||||
mcp_tools: # Optional per-server tool allowlist for the agent's MCP servers
|
||||
github: # (glob patterns: * and ?). Intersects with the global config,
|
||||
- get_* # mcp.json `allowedTools`, and every other configured layer. It
|
||||
- search_* # can only narrow access, never widen it.
|
||||
global_tools: # Optional list of additional global tools to enable for the agent; i.e. not tools specific to the agent
|
||||
- web_search
|
||||
- fs
|
||||
- python
|
||||
skills_enabled: true # Master switch for skills in this agent (default: inherit from global).
|
||||
# Skills also require `function_calling_support: true` in the global config.
|
||||
enabled_skills: # Optional list of skills available when this agent runs.
|
||||
# Must be a subset of global `visible_skills`. Omit to inherit the global default.
|
||||
skills_enabled:
|
||||
true # Master switch for skills in this agent (default: inherit from global).
|
||||
# Skills also require `function_calling_support: true` in the global config.
|
||||
enabled_skills: # Optional list of skills available when this agent runs.
|
||||
# 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)
|
||||
enabled_macros: # Optional list of macros invocable when this agent is active in the REPL.
|
||||
- generate-commit-message # An empty list disables all macros. Omit to inherit the role/global default.
|
||||
memory: null # Per-agent memory override (default: inherit). Set to `false` to disable memory
|
||||
# for this agent regardless of workspace/global presence. See the Memory wiki page.
|
||||
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_macros: # Optional list of macros invocable when this agent is active in the REPL.
|
||||
- generate-commit-message # An empty list disables all macros. Omit to inherit the role/global default.
|
||||
memory:
|
||||
null # Per-agent memory override (default: inherit). Set to `false` to disable memory
|
||||
# for this agent regardless of workspace/global presence. See the Memory wiki page.
|
||||
|
||||
dynamic_instructions: false # Whether to use dynamic instructions for the agent; if false, static instructions are used
|
||||
instructions: | # Static instructions for the agent; ignored if dynamic instructions are used
|
||||
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.
|
||||
|
||||
<tools>
|
||||
@@ -86,12 +98,12 @@ instructions: | # Static instructions for the agent; ignored if
|
||||
<user>
|
||||
username: {{username}}
|
||||
</user>
|
||||
variables: # Optional variables for the agent
|
||||
# The variables defined above like {{__variable_name__}} are automatically available
|
||||
variables: # Optional variables for the agent
|
||||
# The variables defined above like {{__variable_name__}} are automatically available
|
||||
- name: username
|
||||
description: Your user name
|
||||
default: null # A default value for this variable; if null, the variable must be provided when starting the agent
|
||||
conversation_starters: # Optional conversation starters for the agent
|
||||
default: null # A default value for this variable; if null, the variable must be provided when starting the agent
|
||||
conversation_starters: # Optional conversation starters for the agent
|
||||
- What is the meaning of life?
|
||||
- Tell me a joke.
|
||||
- What is the capital of France?
|
||||
@@ -102,15 +114,15 @@ conversation_starters: # Optional conversation starters for the agent
|
||||
- How do I stay motivated?
|
||||
- What is the best way to exercise?
|
||||
- How do I manage my time effectively?
|
||||
documents: # Optional documents to load for the agent
|
||||
# To enable graph-based RAG (entity/relationship extraction + knowledge graph retrieval),
|
||||
# set `rag_extractor_model` in your global config.yaml.
|
||||
# See https://github.com/Dark-Alex-17/coyote/wiki/RAG#graph-based-rag
|
||||
- git:/some/repo # Explicitly tell Coyote to use the 'git' document loader using an absolute path
|
||||
- pdf:some-pdf-file.pdf # Explicitly tell Coyote to use the 'pdf' document loader using a relative path
|
||||
documents: # Optional documents to load for the agent
|
||||
# To enable graph-based RAG (entity/relationship extraction + knowledge graph retrieval),
|
||||
# set `rag_extractor_model` in your global config.yaml.
|
||||
# See https://github.com/Dark-Alex-17/coyote/wiki/RAG#graph-based-rag
|
||||
- git:/some/repo # Explicitly tell Coyote to use the 'git' document loader using an absolute path
|
||||
- pdf:some-pdf-file.pdf # Explicitly tell Coyote to use the 'pdf' document loader using a relative path
|
||||
- https://some-website.com/some-page
|
||||
- some-file.pdf # File with relative path to the <coyote-config-dir>/agents/<agent-name> directory; i.e. file in the same directory as this config file
|
||||
- ~/some-file.txt # File in the user's home directory
|
||||
- /absolute/path/to/some-file.md # File with absolute path
|
||||
- /absolute/path/**/NAME.txt # Find all NAME.txt files in the specified directory and all its subdirectories
|
||||
- some-file.pdf # File with relative path to the <coyote-config-dir>/agents/<agent-name> directory; i.e. file in the same directory as this config file
|
||||
- ~/some-file.txt # File in the user's home directory
|
||||
- /absolute/path/to/some-file.md # File with absolute path
|
||||
- /absolute/path/**/NAME.txt # Find all NAME.txt files in the specified directory and all its subdirectories
|
||||
- /absolute/path/to/*/README.md # Find all README.md files in all immediate subdirectories of the specified directory (depth=1)
|
||||
|
||||
+15
-1
@@ -93,7 +93,8 @@ enabled_tools: null # Which tools to enable by default.
|
||||
# - web_search_coyote
|
||||
# Example (comma-separated form):
|
||||
# enabled_tools: fs,web_search_coyote
|
||||
visible_tools: # Which tools are visible to be compiled (and are thus able to be defined in 'enabled_tools')
|
||||
visible_tools: # Which tools are visible to be compiled (and are thus able to be defined in 'enabled_tools').
|
||||
# Null/missing = all tools in the global tools dir are visible; [] = none.
|
||||
# - ast_grep.sh
|
||||
# - demo_py.py
|
||||
# - demo_sh.sh
|
||||
@@ -137,6 +138,18 @@ enabled_mcp_servers: null # Which MCP servers to enable by default.
|
||||
# - slack
|
||||
# Example (comma-separated form):
|
||||
# enabled_mcp_servers: github,slack,ddg-search
|
||||
mcp_tools: null # Per-server MCP tool allowlists (glob patterns: * and ? supported).
|
||||
# Tools that match no pattern are hidden from the model as if they
|
||||
# don't exist. Stacks with the other allowlist layers (mcp.json
|
||||
# `allowedTools`, role, agent, session, skill, graph node). Every
|
||||
# configured layer must allow a tool, so layers only ever narrow.
|
||||
# An empty list blocks all of a server's tools.
|
||||
# Example:
|
||||
# mcp_tools:
|
||||
# github:
|
||||
# - get_*
|
||||
# - list_*
|
||||
# slack: []
|
||||
no_workspace_mcp: false # Disable loading workspace-local MCP servers (default: false).
|
||||
# When false (the default), Coyote merges the first workspace MCP config it finds
|
||||
# into the global MCP registry at startup, checking in order:
|
||||
@@ -204,6 +217,7 @@ summary_context_prompt: > # The text prompt used for including the summar
|
||||
'This is a summary of the chat history as a recap: '
|
||||
compression_keep_last: 0 # Number of most-recent messages to keep visible after compression (0 = compress all messages)
|
||||
max_tool_result_chars: null # Cap on tool result characters forwarded to the model per call (null = no cap)
|
||||
max_concurrent_jobs: 5 # Max background jobs (`job__*` tools) running at once per context (default: 5; 0 disables background jobs entirely)
|
||||
|
||||
# ---- Memory ----
|
||||
# See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information.
|
||||
|
||||
@@ -16,6 +16,10 @@ enabled_tools: # Tools to enable for this role. Accepts a
|
||||
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.
|
||||
mcp_tools: # Per-server MCP tool allowlists for this role (globs: * and ?).
|
||||
github: # Intersects with the global config / mcp.json `allowedTools`.
|
||||
- get_* # Layers only narrow. Tools matching no pattern are hidden from
|
||||
- search_* # the model as if they don't exist.
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Documentation: https://docs.brew.sh/Formula-Cookbook
|
||||
# https://rubydoc.brew.sh/Formula
|
||||
class Coyote < Formula
|
||||
desc "All-in-one, batteries included LLM CLI tool"
|
||||
desc "The batteries-included runtime for LLMs"
|
||||
homepage "https://github.com/Dark-Alex-17/coyote"
|
||||
if OS.mac? and Hardware::CPU.arm?
|
||||
url "https://github.com/Dark-Alex-17/coyote/releases/download/v$version/coyote-aarch64-apple-darwin.tar.gz"
|
||||
|
||||
@@ -36,6 +36,14 @@ top_p: null # Default sampling top-p for `llm` nodes
|
||||
reasoning_effort: null # Default reasoning effort for `llm` nodes that don't override it.
|
||||
# Only valid when the model declares reasoning_levels.
|
||||
|
||||
max_concurrent_jobs: 5 # Max background jobs (`job__*` tools) running at once across the
|
||||
# whole graph run: every `llm` node (including parallel branches)
|
||||
# draws from this one pool, so the budget is graph-wide — there is
|
||||
# no per-node override. Jobs themselves are node-local: the node
|
||||
# that starts a job must collect or cancel it before it ends, and
|
||||
# anything left running at node exit is cancelled. Overrides the
|
||||
# global setting; 0 disables background jobs for this graph agent.
|
||||
|
||||
global_tools: # Tool universe an `llm` node's `tools:` whitelist draws from
|
||||
- web_search_coyote.sh
|
||||
- fetch_url_via_curl.sh
|
||||
@@ -43,6 +51,10 @@ global_tools: # Tool universe an `llm` node's `tools:` whit
|
||||
mcp_servers: # MCP servers an `llm` node may reference via `mcp:<server>`
|
||||
- ddg-search
|
||||
|
||||
mcp_tools: # Optional per-server tool allowlists (globs: * and ?) applied to
|
||||
ddg-search: # every node that uses `mcp:<server>`; intersects with the other
|
||||
- search # allowlist layers (global config, agent, mcp.json `allowedTools`).
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skills policy (optional)
|
||||
# Skills only attach to `llm` nodes inside a graph. Both fields are optional.
|
||||
@@ -394,6 +406,9 @@ nodes:
|
||||
tools: # Narrow whitelist: exactly these entries, nothing else
|
||||
- web_search_coyote # an exact global-tool / custom-tool name
|
||||
- mcp:ddg-search # `mcp:<server>` includes that server's functions
|
||||
mcp_tools: # Optional per-node narrowing of MCP tools (globs: * and ?)
|
||||
ddg-search: # keys must be servers this graph enables; intersects with
|
||||
- search # the graph-level mcp_tools above and every other layer
|
||||
model: claude:claude-haiku-4-5 # Optional per-node model override
|
||||
temperature: 0.3 # Optional per-node sampling override
|
||||
reasoning_effort: null # Optional per-node reasoning effort override (e.g. low, medium, high)
|
||||
|
||||
+140
-62
@@ -16,6 +16,8 @@ param(
|
||||
[string]$BinDir = $env:BIN_DIR
|
||||
)
|
||||
|
||||
if ($Version -and $Version -match '^[0-9]') { $Version = "v$Version" }
|
||||
|
||||
$Repo = 'Dark-Alex-17/coyote'
|
||||
|
||||
function Write-Info($msg) { Write-Host "[coyote-install] $msg" }
|
||||
@@ -61,79 +63,155 @@ if ($os -eq 'windows') {
|
||||
if ($arch -eq 'x86_64') { $candidates += 'coyote-x86_64-apple-darwin.tar.gz' }
|
||||
else { $candidates += 'coyote-aarch64-apple-darwin.tar.gz' }
|
||||
} elseif ($os -eq 'linux') {
|
||||
if ($arch -eq 'x86_64') {
|
||||
$candidates += 'coyote-x86_64-unknown-linux-gnu.tar.gz'
|
||||
$candidates += 'coyote-x86_64-unknown-linux-musl.tar.gz'
|
||||
} else {
|
||||
$candidates += 'coyote-aarch64-unknown-linux-musl.tar.gz'
|
||||
$libc = 'musl'
|
||||
try { getconf GNU_LIBC_VERSION *> $null; if ($LASTEXITCODE -eq 0) { $libc = 'gnu' } } catch { }
|
||||
try { if ((ldd --version 2>&1 | Out-String) -imatch 'glibc') { $libc = 'gnu' } } catch { }
|
||||
if ($libc -eq 'gnu') {
|
||||
# ldconfig lives in /usr/sbin on Debian/Ubuntu, often missing from non-root
|
||||
# PATHs, so try its known locations and fall back to probing library dirs.
|
||||
$libssl3 = $false
|
||||
foreach ($ldc in @('ldconfig', '/sbin/ldconfig', '/usr/sbin/ldconfig')) {
|
||||
try { if ((& $ldc -p 2>&1 | Out-String) -match 'libssl\.so\.3') { $libssl3 = $true; break } } catch { }
|
||||
}
|
||||
if (-not $libssl3) {
|
||||
foreach ($libDir in @('/usr/lib/*/libssl.so.3', '/lib/*/libssl.so.3', '/usr/lib64/libssl.so.3', '/usr/lib/libssl.so.3', '/usr/local/lib/libssl.so.3', '/usr/local/lib/*/libssl.so.3')) {
|
||||
if (Get-Item -Path $libDir -ErrorAction SilentlyContinue) { $libssl3 = $true; break }
|
||||
}
|
||||
}
|
||||
if ($libssl3) {
|
||||
$candidates += "coyote-$arch-unknown-linux-gnu.tar.gz"
|
||||
} else {
|
||||
Write-Info "glibc detected but OpenSSL 3 (libssl.so.3) not found; using musl build"
|
||||
}
|
||||
}
|
||||
$candidates += "coyote-$arch-unknown-linux-musl.tar.gz"
|
||||
} else {
|
||||
Fail "Unsupported OS for this installer: $os"
|
||||
}
|
||||
|
||||
$asset = $null
|
||||
foreach ($c in $candidates) {
|
||||
$asset = $release.assets | Where-Object { $_.name -eq $c } | Select-Object -First 1
|
||||
if ($asset) { break }
|
||||
}
|
||||
if (-not $asset) {
|
||||
Write-Error "No matching asset found for $os-$arch. Tried:"; $candidates | ForEach-Object { Write-Error " - $_" }
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Info "Selected asset: $($asset.name)"
|
||||
Write-Info "Download URL: $($asset.browser_download_url)"
|
||||
|
||||
$tmp = New-Item -ItemType Directory -Force -Path ([IO.Path]::Combine([IO.Path]::GetTempPath(), "coyote-$(Get-Random)"))
|
||||
$archive = Join-Path $tmp.FullName 'asset'
|
||||
try { Invoke-WebRequest -UseBasicParsing -Headers @{ 'User-Agent' = 'coyote-installer' } -Uri $asset.browser_download_url -OutFile $archive } catch { Fail "Failed to download asset. $_" }
|
||||
|
||||
$extractDir = Join-Path $tmp.FullName 'extract'; New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
|
||||
try {
|
||||
$exec = if ($isWin) { 'coyote.exe' } else { 'coyote' }
|
||||
$dest = Join-Path $BinDir $exec
|
||||
|
||||
if ($asset.name -match '\.zip$') {
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $extractDir)
|
||||
} elseif ($asset.name -match '\.tar\.gz$' -or $asset.name -match '\.tgz$') {
|
||||
$tar = Get-Command tar -ErrorAction SilentlyContinue
|
||||
if ($tar) { & $tar.Source -xzf $archive -C $extractDir }
|
||||
else { Fail "Asset is tar archive but 'tar' is not available." }
|
||||
} else {
|
||||
try { Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $extractDir) }
|
||||
catch {
|
||||
$tar = Get-Command tar -ErrorAction SilentlyContinue
|
||||
if ($tar) { & $tar.Source -xf $archive -C $extractDir } else { Fail "Unknown archive format; neither zip nor tar workable." }
|
||||
$installed = $false
|
||||
$tried = @()
|
||||
$attempt = 0
|
||||
foreach ($c in $candidates) {
|
||||
$asset = $release.assets | Where-Object { $_.name -eq $c } | Select-Object -First 1
|
||||
if (-not $asset) {
|
||||
$tried += "${c}: no matching release asset"
|
||||
continue
|
||||
}
|
||||
|
||||
$attempt++
|
||||
$work = New-Item -ItemType Directory -Force -Path (Join-Path $tmp.FullName "attempt-$attempt")
|
||||
|
||||
Write-Info "Selected asset: $($asset.name)"
|
||||
Write-Info "Download URL: $($asset.browser_download_url)"
|
||||
|
||||
$archive = Join-Path $work.FullName 'asset'
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Headers @{ 'User-Agent' = 'coyote-installer' } -Uri $asset.browser_download_url -OutFile $archive
|
||||
} catch {
|
||||
Write-Info "Failed to download ${c}; trying next candidate. $_"
|
||||
$tried += "${c}: download failed"
|
||||
continue
|
||||
}
|
||||
|
||||
$extractDir = Join-Path $work.FullName 'extract'; New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
|
||||
|
||||
try {
|
||||
if ($asset.name -match '\.zip$') {
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $extractDir)
|
||||
} elseif ($asset.name -match '\.tar\.gz$' -or $asset.name -match '\.tgz$') {
|
||||
$tar = Get-Command tar -ErrorAction SilentlyContinue
|
||||
if ($tar) { & $tar.Source -xzf $archive -C $extractDir }
|
||||
else { throw "Asset is tar archive but 'tar' is not available." }
|
||||
} else {
|
||||
try { Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $extractDir) }
|
||||
catch {
|
||||
$tar = Get-Command tar -ErrorAction SilentlyContinue
|
||||
if ($tar) { & $tar.Source -xf $archive -C $extractDir } else { throw "Unknown archive format; neither zip nor tar workable." }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Info "Failed to extract ${c}; trying next candidate. $_"
|
||||
$tried += "${c}: extract failed"
|
||||
continue
|
||||
}
|
||||
|
||||
$bin = $null
|
||||
Get-ChildItem -Recurse -File $extractDir | ForEach-Object {
|
||||
if ($isWin) { if ($_.Name -ieq 'coyote.exe') { $bin = $_.FullName } }
|
||||
else { if ($_.Name -ieq 'coyote') { $bin = $_.FullName } }
|
||||
}
|
||||
if (-not $bin) {
|
||||
Write-Info "Could not find coyote binary inside ${c}; trying next candidate"
|
||||
$tried += "${c}: no coyote binary in archive"
|
||||
continue
|
||||
}
|
||||
|
||||
if (-not $isWin) { try { & chmod +x -- $bin } catch {} }
|
||||
|
||||
$works = $false
|
||||
try { & $bin --version *> $null; if ($LASTEXITCODE -eq 0) { $works = $true } } catch { }
|
||||
if (-not $works -and -not $isWin) {
|
||||
# The temp dir may live on a noexec mount; retry from a probe file in
|
||||
# the install directory before rejecting.
|
||||
$probe = Join-Path $BinDir ".coyote-install-probe-$PID"
|
||||
try {
|
||||
Copy-Item -Force $bin $probe
|
||||
& chmod +x -- $probe
|
||||
& $probe --version *> $null
|
||||
if ($LASTEXITCODE -eq 0) { $works = $true }
|
||||
} catch { } finally {
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue $probe
|
||||
}
|
||||
}
|
||||
if (-not $works) {
|
||||
Write-Info "Downloaded $c but it failed to run on this system; trying next candidate"
|
||||
$tried += "${c}: binary failed to run on this system"
|
||||
continue
|
||||
}
|
||||
|
||||
Copy-Item -Force $bin $dest
|
||||
Write-Info "Installed: $dest"
|
||||
$installed = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$bin = $null
|
||||
Get-ChildItem -Recurse -File $extractDir | ForEach-Object {
|
||||
if ($isWin) { if ($_.Name -ieq 'coyote.exe') { $bin = $_.FullName } }
|
||||
else { if ($_.Name -ieq 'coyote') { $bin = $_.FullName } }
|
||||
}
|
||||
if (-not $bin) { Fail "Could not find coyote binary inside the archive." }
|
||||
if (-not $installed) {
|
||||
Write-Error "No usable asset found for $os-$arch. Tried:"
|
||||
$tried | ForEach-Object { Write-Error " - $_" }
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not $isWin) { try { & chmod +x -- $bin } catch {} }
|
||||
|
||||
$exec = if ($isWin) { 'coyote.exe'} else { 'coyote' }
|
||||
$dest = Join-Path $BinDir $exec
|
||||
Copy-Item -Force $bin $dest
|
||||
Write-Info "Installed: $dest"
|
||||
|
||||
if ($isWin) {
|
||||
$pathParts = ($env:Path -split ';') | Where-Object { $_ -ne '' }
|
||||
if ($pathParts -notcontains $BinDir) {
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User'); if (-not $userPath) { $userPath = '' }
|
||||
if (-not ($userPath -split ';' | Where-Object { $_ -eq $BinDir })) {
|
||||
$newUserPath = if ($userPath.Trim().Length -gt 0) { "$userPath;$BinDir" } else { $BinDir }
|
||||
[Environment]::SetEnvironmentVariable('Path', $newUserPath, 'User')
|
||||
Write-Info "Added to User PATH: $BinDir (restart shell to take effect)"
|
||||
if ($isWin) {
|
||||
$pathParts = ($env:Path -split ';') | Where-Object { $_ -ne '' }
|
||||
if ($pathParts -notcontains $BinDir) {
|
||||
# Read/write the User PATH via the registry directly: the [Environment]
|
||||
# round-trip expands %VAR% entries and bakes them in on write.
|
||||
$regKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
|
||||
if ($regKey) {
|
||||
$userPath = [string]$regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
if (-not (($userPath -split ';') -contains $BinDir)) {
|
||||
$newUserPath = if ($userPath.Trim().Length -gt 0) { "$userPath;$BinDir" } else { $BinDir }
|
||||
$regKey.SetValue('Path', $newUserPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
||||
Write-Info "Added to User PATH: $BinDir (restart shell to take effect)"
|
||||
}
|
||||
$regKey.Close()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (-not ($env:PATH -split ':' | Where-Object { $_ -eq $BinDir })) {
|
||||
Write-Info "Note: $BinDir is not in PATH. Add it to your shell profile."
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (-not ($env:PATH -split ':' | Where-Object { $_ -eq $BinDir })) {
|
||||
Write-Info "Note: $BinDir is not in PATH. Add it to your shell profile."
|
||||
}
|
||||
|
||||
Write-Info "Done. Try: coyote --help"
|
||||
} finally {
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $tmp
|
||||
}
|
||||
|
||||
Write-Info "Done. Try: coyote --help"
|
||||
|
||||
|
||||
+214
-144
@@ -13,8 +13,6 @@ set -euo pipefail
|
||||
# --bin-dir <dir> Install directory (default: /usr/local/bin or ~/.local/bin). Or set BIN_DIR.
|
||||
|
||||
REPO="Dark-Alex-17/coyote"
|
||||
VERSION="${COYOTE_VERSION:-}"
|
||||
BIN_DIR="${BIN_DIR:-}"
|
||||
|
||||
usage() {
|
||||
echo "coyote installer (Linux/macOS)"
|
||||
@@ -25,24 +23,6 @@ usage() {
|
||||
echo " -h, --help Show help"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--version) VERSION="$2"; shift 2;;
|
||||
--bin-dir) BIN_DIR="$2"; shift 2;;
|
||||
-h|--help) usage; exit 0;;
|
||||
*) echo "Unknown argument: $1" >&2; usage; exit 2;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${BIN_DIR}" ]]; then
|
||||
if [[ -w "/usr/local/bin" ]]; then
|
||||
BIN_DIR="/usr/local/bin"
|
||||
else
|
||||
BIN_DIR="${HOME}/.local/bin"
|
||||
fi
|
||||
fi
|
||||
mkdir -p "${BIN_DIR}"
|
||||
|
||||
log() {
|
||||
echo "[coyote-install] $*"
|
||||
}
|
||||
@@ -54,42 +34,6 @@ need_cmd() {
|
||||
fi
|
||||
}
|
||||
|
||||
need_cmd uname
|
||||
need_cmd mktemp
|
||||
need_cmd tar
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
DL=curl
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
DL=wget
|
||||
else
|
||||
echo "Error: need curl or wget" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UNAME_OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
case "$UNAME_OS" in
|
||||
linux) OS=linux ;;
|
||||
darwin) OS=darwin ;;
|
||||
*) echo "Error: unsupported OS '$UNAME_OS'" >&2; exit 1;;
|
||||
esac
|
||||
|
||||
UNAME_ARCH=$(uname -m)
|
||||
case "$UNAME_ARCH" in
|
||||
x86_64|amd64) ARCH=x86_64 ;;
|
||||
aarch64|arm64) ARCH=aarch64 ;;
|
||||
*) echo "Error: unsupported arch '$UNAME_ARCH'" >&2; exit 1;;
|
||||
esac
|
||||
|
||||
log "Target: ${OS}-${ARCH}"
|
||||
|
||||
API_BASE="https://api.github.com/repos/${REPO}/releases"
|
||||
if [[ -z "${VERSION}" ]]; then
|
||||
RELEASE_URL="${API_BASE}/latest"
|
||||
else
|
||||
RELEASE_URL="${API_BASE}/tags/${VERSION}"
|
||||
fi
|
||||
|
||||
http_get() {
|
||||
if [[ "$DL" == "curl" ]]; then
|
||||
curl -fsSL -H 'User-Agent: coyote-installer' "$1"
|
||||
@@ -98,114 +42,240 @@ http_get() {
|
||||
fi
|
||||
}
|
||||
|
||||
TMPDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
log "Fetching release metadata from $RELEASE_URL"
|
||||
JSON="$TMPDIR/release.json"
|
||||
if ! http_get "$RELEASE_URL" > "$JSON"; then
|
||||
echo "Error: failed to fetch release metadata. Check version tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ASSET_CANDIDATES=()
|
||||
if [[ "$OS" == "darwin" ]]; then
|
||||
if [[ "$ARCH" == "x86_64" ]]; then
|
||||
ASSET_CANDIDATES+=("coyote-x86_64-apple-darwin.tar.gz")
|
||||
else
|
||||
ASSET_CANDIDATES+=("coyote-aarch64-apple-darwin.tar.gz")
|
||||
smoke_test() {
|
||||
# The scratch dir may live on a noexec mount; if running in place fails,
|
||||
# retry from a probe file in the install directory before rejecting.
|
||||
local bin="$1"
|
||||
if "$bin" --version >/dev/null 2>&1; then return 0; fi
|
||||
local probe="${BIN_DIR}/.coyote-install-probe.$$"
|
||||
local ok=1
|
||||
if cp "$bin" "$probe" 2>/dev/null && chmod +x "$probe" 2>/dev/null; then
|
||||
if "$probe" --version >/dev/null 2>&1; then ok=0; fi
|
||||
fi
|
||||
elif [[ "$OS" == "linux" ]]; then
|
||||
if [[ "$ARCH" == "x86_64" ]]; then
|
||||
rm -f "$probe"
|
||||
return "$ok"
|
||||
}
|
||||
|
||||
main() {
|
||||
VERSION="${COYOTE_VERSION:-}"
|
||||
BIN_DIR="${BIN_DIR:-}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--version) VERSION="$2"; shift 2;;
|
||||
--bin-dir) BIN_DIR="$2"; shift 2;;
|
||||
-h|--help) usage; exit 0;;
|
||||
*) echo "Unknown argument: $1" >&2; usage; exit 2;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "$VERSION" && "$VERSION" =~ ^[0-9] ]]; then VERSION="v${VERSION}"; fi
|
||||
|
||||
if [[ -z "${BIN_DIR}" ]]; then
|
||||
if [[ -w "/usr/local/bin" ]]; then
|
||||
BIN_DIR="/usr/local/bin"
|
||||
else
|
||||
BIN_DIR="${HOME}/.local/bin"
|
||||
fi
|
||||
fi
|
||||
mkdir -p "${BIN_DIR}"
|
||||
|
||||
need_cmd uname
|
||||
need_cmd mktemp
|
||||
need_cmd tar
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
DL=curl
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
DL=wget
|
||||
else
|
||||
echo "Error: need curl or wget" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UNAME_OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
case "$UNAME_OS" in
|
||||
linux) OS=linux ;;
|
||||
darwin) OS=darwin ;;
|
||||
*) echo "Error: unsupported OS '$UNAME_OS'" >&2; exit 1;;
|
||||
esac
|
||||
|
||||
UNAME_ARCH=$(uname -m)
|
||||
case "$UNAME_ARCH" in
|
||||
x86_64|amd64) ARCH=x86_64 ;;
|
||||
aarch64|arm64) ARCH=aarch64 ;;
|
||||
*) echo "Error: unsupported arch '$UNAME_ARCH'" >&2; exit 1;;
|
||||
esac
|
||||
|
||||
log "Target: ${OS}-${ARCH}"
|
||||
|
||||
API_BASE="https://api.github.com/repos/${REPO}/releases"
|
||||
if [[ -z "${VERSION}" ]]; then
|
||||
RELEASE_URL="${API_BASE}/latest"
|
||||
else
|
||||
RELEASE_URL="${API_BASE}/tags/${VERSION}"
|
||||
fi
|
||||
|
||||
WORKDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORKDIR"; rm -f "${BIN_DIR}/.coyote-install-probe.$$"' EXIT
|
||||
|
||||
log "Fetching release metadata from $RELEASE_URL"
|
||||
JSON="$WORKDIR/release.json"
|
||||
if ! http_get "$RELEASE_URL" > "$JSON"; then
|
||||
echo "Error: failed to fetch release metadata. Check version tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ASSET_CANDIDATES=()
|
||||
if [[ "$OS" == "darwin" ]]; then
|
||||
if [[ "$ARCH" == "x86_64" ]]; then
|
||||
ASSET_CANDIDATES+=("coyote-x86_64-apple-darwin.tar.gz")
|
||||
else
|
||||
ASSET_CANDIDATES+=("coyote-aarch64-apple-darwin.tar.gz")
|
||||
fi
|
||||
elif [[ "$OS" == "linux" ]]; then
|
||||
LIBC="musl"
|
||||
if command -v getconf >/dev/null 2>&1 && getconf GNU_LIBC_VERSION >/dev/null 2>&1; then LIBC="gnu"; fi
|
||||
if ldd --version 2>&1 | grep -qi glibc; then LIBC="gnu"; fi
|
||||
|
||||
if [[ "$LIBC" == "gnu" ]]; then
|
||||
ASSET_CANDIDATES+=("coyote-x86_64-unknown-linux-gnu.tar.gz")
|
||||
# The gnu binary dynamically links OpenSSL 3. On Debian/Ubuntu, ldconfig lives
|
||||
# in /usr/sbin, which is often missing from non-root PATHs, so try its known
|
||||
# locations and fall back to probing the usual library directories directly.
|
||||
LIBSSL3=""
|
||||
for LDCONFIG in ldconfig /sbin/ldconfig /usr/sbin/ldconfig; do
|
||||
if command -v "$LDCONFIG" >/dev/null 2>&1; then
|
||||
if "$LDCONFIG" -p 2>/dev/null | grep -q 'libssl\.so\.3'; then LIBSSL3="yes"; fi
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "$LIBSSL3" ]]; then
|
||||
for LIBSSL_CANDIDATE in /usr/lib/*/libssl.so.3 /lib/*/libssl.so.3 /usr/lib64/libssl.so.3 /usr/lib/libssl.so.3 /usr/local/lib/libssl.so.3 /usr/local/lib/*/libssl.so.3; do
|
||||
if [[ -e "$LIBSSL_CANDIDATE" ]]; then LIBSSL3="yes"; break; fi
|
||||
done
|
||||
fi
|
||||
if [[ -n "$LIBSSL3" ]]; then
|
||||
ASSET_CANDIDATES+=("coyote-${ARCH}-unknown-linux-gnu.tar.gz")
|
||||
else
|
||||
log "glibc detected but OpenSSL 3 (libssl.so.3) not found; using musl build"
|
||||
fi
|
||||
fi
|
||||
|
||||
ASSET_CANDIDATES+=("coyote-x86_64-unknown-linux-musl.tar.gz")
|
||||
ASSET_CANDIDATES+=("coyote-${ARCH}-unknown-linux-musl.tar.gz")
|
||||
else
|
||||
ASSET_CANDIDATES+=("coyote-aarch64-unknown-linux-musl.tar.gz")
|
||||
echo "Error: unsupported OS for this installer: $OS" >&2; exit 1
|
||||
fi
|
||||
else
|
||||
echo "Error: unsupported OS for this installer: $OS" >&2; exit 1
|
||||
fi
|
||||
|
||||
DL_URLS=$(grep -oE '"browser_download_url":[[:space:]]*"[^"]+"' "$JSON" \
|
||||
| sed -E 's/.*"browser_download_url":[[:space:]]*"//; s/"$//' \
|
||||
|| true)
|
||||
DL_URLS=$(grep -oE '"browser_download_url":[[:space:]]*"[^"]+"' "$JSON" \
|
||||
| sed -E 's/.*"browser_download_url":[[:space:]]*"//; s/"$//' \
|
||||
|| true)
|
||||
|
||||
ASSET_NAME=""; ASSET_URL=""
|
||||
for candidate in "${ASSET_CANDIDATES[@]}"; do
|
||||
while IFS= read -r url; do
|
||||
[[ -z "$url" ]] && continue
|
||||
if [[ "$url" == */"$candidate" ]]; then
|
||||
ASSET_NAME="$candidate"
|
||||
ASSET_URL="$url"
|
||||
break
|
||||
INSTALLED=""
|
||||
TRIED=()
|
||||
ATTEMPT=0
|
||||
for candidate in "${ASSET_CANDIDATES[@]}"; do
|
||||
ASSET_URL=""
|
||||
while IFS= read -r url; do
|
||||
[[ -z "$url" ]] && continue
|
||||
if [[ "$url" == */"$candidate" ]]; then
|
||||
ASSET_URL="$url"
|
||||
break
|
||||
fi
|
||||
done <<< "$DL_URLS"
|
||||
|
||||
if [[ -z "$ASSET_URL" ]]; then
|
||||
TRIED+=("$candidate: no matching release asset")
|
||||
continue
|
||||
fi
|
||||
done <<< "$DL_URLS"
|
||||
[[ -n "$ASSET_URL" ]] && break
|
||||
done
|
||||
|
||||
if [[ -z "$ASSET_URL" ]]; then
|
||||
echo "Error: no matching asset found for ${OS}-${ARCH}. Tried:" >&2
|
||||
for c in "${ASSET_CANDIDATES[@]}"; do echo " - $c" >&2; done
|
||||
exit 1
|
||||
fi
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
WORK="$WORKDIR/attempt-$ATTEMPT"
|
||||
mkdir -p "$WORK"
|
||||
|
||||
log "Selected asset: $ASSET_NAME"
|
||||
log "Download URL: $ASSET_URL"
|
||||
log "Selected asset: $candidate"
|
||||
log "Download URL: $ASSET_URL"
|
||||
|
||||
ARCHIVE="$TMPDIR/asset"
|
||||
if [[ "$DL" == "curl" ]]; then
|
||||
curl -fL -H 'User-Agent: coyote-installer' "$ASSET_URL" -o "$ARCHIVE"
|
||||
else
|
||||
wget -q --header='User-Agent: coyote-installer' "$ASSET_URL" -O "$ARCHIVE"
|
||||
fi
|
||||
ARCHIVE="$WORK/asset"
|
||||
if [[ "$DL" == "curl" ]]; then
|
||||
if ! curl -fL -H 'User-Agent: coyote-installer' "$ASSET_URL" -o "$ARCHIVE"; then
|
||||
log "Failed to download $candidate; trying next candidate"
|
||||
TRIED+=("$candidate: download failed")
|
||||
continue
|
||||
fi
|
||||
else
|
||||
if ! wget -q --header='User-Agent: coyote-installer' "$ASSET_URL" -O "$ARCHIVE"; then
|
||||
log "Failed to download $candidate; trying next candidate"
|
||||
TRIED+=("$candidate: download failed")
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
WORK="$TMPDIR/work"; mkdir -p "$WORK"
|
||||
EXTRACTED_DIR="$WORK/extracted"; mkdir -p "$EXTRACTED_DIR"
|
||||
EXTRACTED_DIR="$WORK/extracted"; mkdir -p "$EXTRACTED_DIR"
|
||||
|
||||
if tar -tf "$ARCHIVE" >/dev/null 2>&1; then
|
||||
tar -xzf "$ARCHIVE" -C "$EXTRACTED_DIR"
|
||||
else
|
||||
if command -v unzip >/dev/null 2>&1; then
|
||||
unzip -q "$ARCHIVE" -d "$EXTRACTED_DIR"
|
||||
else
|
||||
echo "Error: unknown archive format; install 'unzip'" >&2
|
||||
exit 1
|
||||
if tar -tf "$ARCHIVE" >/dev/null 2>&1; then
|
||||
if ! tar -xzf "$ARCHIVE" -C "$EXTRACTED_DIR"; then
|
||||
log "Failed to extract $candidate; trying next candidate"
|
||||
TRIED+=("$candidate: extract failed")
|
||||
continue
|
||||
fi
|
||||
else
|
||||
if command -v unzip >/dev/null 2>&1; then
|
||||
if ! unzip -q "$ARCHIVE" -d "$EXTRACTED_DIR"; then
|
||||
log "Failed to extract $candidate; trying next candidate"
|
||||
TRIED+=("$candidate: extract failed")
|
||||
continue
|
||||
fi
|
||||
else
|
||||
log "Unknown archive format for $candidate and 'unzip' is not available; trying next candidate"
|
||||
TRIED+=("$candidate: unknown archive format and 'unzip' unavailable")
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
BIN_PATH=""
|
||||
while IFS= read -r -d '' f; do
|
||||
base=$(basename "$f")
|
||||
if [[ "$base" == "coyote" ]]; then
|
||||
BIN_PATH="$f"
|
||||
break
|
||||
fi
|
||||
done < <(find "$EXTRACTED_DIR" -type f -print0)
|
||||
|
||||
if [[ -z "$BIN_PATH" ]]; then
|
||||
log "Could not find 'coyote' binary in $candidate; trying next candidate"
|
||||
TRIED+=("$candidate: no 'coyote' binary in archive")
|
||||
continue
|
||||
fi
|
||||
|
||||
chmod +x "$BIN_PATH"
|
||||
if ! smoke_test "$BIN_PATH"; then
|
||||
log "Downloaded $candidate but it failed to run on this system; trying next candidate"
|
||||
TRIED+=("$candidate: binary failed to run on this system")
|
||||
continue
|
||||
fi
|
||||
|
||||
install -m 0755 "$BIN_PATH" "${BIN_DIR}/coyote"
|
||||
INSTALLED="$candidate"
|
||||
break
|
||||
done
|
||||
|
||||
if [[ -z "$INSTALLED" ]]; then
|
||||
echo "Error: no usable asset found for ${OS}-${ARCH}. Tried:" >&2
|
||||
for t in "${TRIED[@]}"; do echo " - $t" >&2; done
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
BIN_PATH=""
|
||||
while IFS= read -r -d '' f; do
|
||||
base=$(basename "$f")
|
||||
if [[ "$base" == "coyote" ]]; then
|
||||
BIN_PATH="$f"
|
||||
break
|
||||
fi
|
||||
done < <(find "$EXTRACTED_DIR" -type f -print0)
|
||||
log "Installed: ${BIN_DIR}/coyote"
|
||||
|
||||
if [[ -z "$BIN_PATH" ]]; then
|
||||
echo "Error: could not find 'coyote' binary in the archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
case ":$PATH:" in
|
||||
*":${BIN_DIR}:"*) ;;
|
||||
*)
|
||||
log "Note: ${BIN_DIR} is not in PATH. Add it, e.g.:"
|
||||
log " export PATH=\"${BIN_DIR}:\$PATH\""
|
||||
;;
|
||||
esac
|
||||
|
||||
chmod +x "$BIN_PATH"
|
||||
install -m 0755 "$BIN_PATH" "${BIN_DIR}/coyote"
|
||||
|
||||
log "Installed: ${BIN_DIR}/coyote"
|
||||
|
||||
case ":$PATH:" in
|
||||
*":${BIN_DIR}:"*) ;;
|
||||
*)
|
||||
log "Note: ${BIN_DIR} is not in PATH. Add it, e.g.:"
|
||||
log " export PATH=\"${BIN_DIR}:\$PATH\""
|
||||
;;
|
||||
esac
|
||||
|
||||
log "Done. Try: coyote --help"
|
||||
log "Done. Try: coyote --help"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
use super::types::{METHOD_NOT_FOUND, PARSE_ERROR, Request, Response};
|
||||
use crate::client::call_chat_completions_streaming;
|
||||
use crate::config::{Input, RenderMode, RequestContext};
|
||||
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
||||
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
||||
use crate::utils;
|
||||
use crate::utils::AbortSignal;
|
||||
use anyhow::Result;
|
||||
@@ -211,7 +211,7 @@ async fn run_prompt_turn(
|
||||
input = input.merge_tool_results(output, tool_results);
|
||||
continue;
|
||||
}
|
||||
match check_pending_agents_guardrail(ctx) {
|
||||
match check_pending_tasks_guardrail(ctx) {
|
||||
GuardrailAction::Inject(prompt) => {
|
||||
input = Input::from_str(ctx, &prompt, None)?;
|
||||
}
|
||||
|
||||
+48
-7
@@ -47,7 +47,7 @@ pub enum McpScopeArg {
|
||||
.args(["sandbox", "fresh"])
|
||||
.multiple(true)
|
||||
.conflicts_with_all([
|
||||
"model", "prompt", "role", "session", "agent", "rag", "rebuild_rag",
|
||||
"model", "temp_role", "role", "session", "agent", "rag", "rebuild_rag",
|
||||
"macro_name", "execute", "code", "file", "no_stream", "no_memory",
|
||||
"init_memory", "dry_run", "info", "build_tools", "install",
|
||||
"install_builtins", "sync_models", "list_models", "list_roles",
|
||||
@@ -70,9 +70,9 @@ pub struct Cli {
|
||||
/// Select a LLM model
|
||||
#[arg(short, long, add = ArgValueCompleter::new(model_completer))]
|
||||
pub model: Option<String>,
|
||||
/// Use the system prompt
|
||||
/// Set a temporary role (an ad-hoc system prompt) for this invocation
|
||||
#[arg(long)]
|
||||
pub prompt: Option<String>,
|
||||
pub temp_role: Option<String>,
|
||||
/// Select a role
|
||||
#[arg(short, long, add = ArgValueCompleter::new(role_completer))]
|
||||
pub role: Option<String>,
|
||||
@@ -315,7 +315,7 @@ pub struct Cli {
|
||||
/// URL for http/sse MCP server (used with --mcp-add)
|
||||
#[arg(long, value_name = "URL", help_heading = "MCP Servers")]
|
||||
pub url: Option<String>,
|
||||
/// Scope for MCP config: user (~/.config/coyote/functions/mcp.json) or workspace (./.coyote/mcp.json). Default: user
|
||||
/// Scope for MCP config: user (~/.config/coyote/mcp.json) or workspace (./.coyote/mcp.json). Default: user
|
||||
#[arg(long, value_enum, value_name = "SCOPE", help_heading = "MCP Servers")]
|
||||
pub scope: Option<McpScopeArg>,
|
||||
/// Environment variable for stdio MCP server (repeatable): --env KEY=VALUE
|
||||
@@ -590,12 +590,33 @@ mod tests {
|
||||
parse(&["--install-builtins", "agents"]).install_builtins,
|
||||
Some(AssetCategory::Agents)
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--install-builtins", "mcp-config"]).install_builtins,
|
||||
Some(AssetCategory::McpConfig)
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--install-builtins", "mcp_config"]).install_builtins,
|
||||
Some(AssetCategory::McpConfig)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_config_canonical_value_is_kebab_case() {
|
||||
use clap::ValueEnum;
|
||||
|
||||
let category = AssetCategory::McpConfig.to_possible_value().unwrap();
|
||||
assert_eq!(category.get_name(), "mcp-config");
|
||||
assert!(category.get_name_and_aliases().any(|n| n == "mcp_config"));
|
||||
assert!(AssetCategory::NAMES.contains(&"mcp-config"));
|
||||
assert!(!AssetCategory::NAMES.contains(&"mcp_config"));
|
||||
|
||||
let filter = InstallFilter::McpConfig.to_possible_value().unwrap();
|
||||
assert_eq!(filter.get_name(), "mcp-config");
|
||||
assert!(filter.get_name_and_aliases().any(|n| n == "mcp_config"));
|
||||
assert!(InstallFilter::NAMES.contains(&"mcp-config"));
|
||||
assert!(!InstallFilter::NAMES.contains(&"mcp_config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_install_builtins_conflicts_with_install() {
|
||||
assert!(
|
||||
@@ -633,6 +654,26 @@ mod tests {
|
||||
parse(&["--install", "https://github.com/x/y", "--filter", "agents"]).filter,
|
||||
Some(InstallFilter::Agents)
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&[
|
||||
"--install",
|
||||
"https://github.com/x/y",
|
||||
"--filter",
|
||||
"mcp-config"
|
||||
])
|
||||
.filter,
|
||||
Some(InstallFilter::McpConfig)
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&[
|
||||
"--install",
|
||||
"https://github.com/x/y",
|
||||
"--filter",
|
||||
"mcp_config"
|
||||
])
|
||||
.filter,
|
||||
Some(InstallFilter::McpConfig)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -705,9 +746,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_prompt_flag() {
|
||||
let cli = parse(&["--prompt", "be a pirate"]);
|
||||
assert_eq!(cli.prompt, Some("be a pirate".to_string()));
|
||||
fn parse_temp_role_flag() {
|
||||
let cli = parse(&["--temp-role", "be a pirate"]);
|
||||
assert_eq!(cli.temp_role, Some("be a pirate".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+125
-4
@@ -3,18 +3,23 @@ use super::*;
|
||||
use crate::{
|
||||
client::Model,
|
||||
config::memory,
|
||||
function::{Functions, run_llm_function},
|
||||
function::{
|
||||
Functions,
|
||||
jobs::{DEFAULT_MAX_CONCURRENT_JOBS, JOB_FUNCTION_PREFIX},
|
||||
run_llm_function,
|
||||
},
|
||||
graph, rag,
|
||||
};
|
||||
|
||||
use super::rag_cache::RagKey;
|
||||
use crate::config::paths;
|
||||
use crate::config::prompts::{
|
||||
DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS, DEFAULT_TODO_INSTRUCTIONS,
|
||||
DEFAULT_USER_INTERACTION_INSTRUCTIONS,
|
||||
DEFAULT_JOB_INSTRUCTIONS, DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS,
|
||||
DEFAULT_TODO_INSTRUCTIONS, DEFAULT_USER_INTERACTION_INSTRUCTIONS,
|
||||
};
|
||||
use crate::graph::types::RagNode;
|
||||
use crate::graph::{Graph, GraphParser, NodeType};
|
||||
use crate::mcp::McpServerFeatures;
|
||||
use crate::rag::RagInitConfig;
|
||||
use crate::vault::SECRET_RE;
|
||||
use anyhow::{Context, Result};
|
||||
@@ -224,6 +229,16 @@ impl Agent {
|
||||
functions.append_supervisor_functions();
|
||||
}
|
||||
|
||||
if app.function_calling_support
|
||||
&& agent_config
|
||||
.max_concurrent_jobs
|
||||
.or(app.max_concurrent_jobs)
|
||||
.unwrap_or(DEFAULT_MAX_CONCURRENT_JOBS)
|
||||
> 0
|
||||
{
|
||||
functions.append_job_functions();
|
||||
}
|
||||
|
||||
functions.append_teammate_functions();
|
||||
functions.append_user_interaction_functions();
|
||||
|
||||
@@ -380,7 +395,7 @@ impl Agent {
|
||||
self.graph_rags.get(node_id).cloned()
|
||||
}
|
||||
|
||||
pub fn append_mcp_meta_functions(&mut self, mcp_servers: Vec<String>) {
|
||||
pub fn append_mcp_meta_functions(&mut self, mcp_servers: Vec<McpServerFeatures>) {
|
||||
self.functions.append_mcp_meta_functions(mcp_servers);
|
||||
}
|
||||
|
||||
@@ -439,6 +454,18 @@ impl Agent {
|
||||
output.push_str(DEFAULT_SPAWN_INSTRUCTIONS);
|
||||
}
|
||||
|
||||
if self
|
||||
.functions
|
||||
.declarations()
|
||||
.iter()
|
||||
.any(|f| f.name.starts_with(JOB_FUNCTION_PREFIX))
|
||||
{
|
||||
if !output.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str(DEFAULT_JOB_INSTRUCTIONS);
|
||||
}
|
||||
|
||||
output.push_str(DEFAULT_TEAMMATE_INSTRUCTIONS);
|
||||
output.push_str(DEFAULT_USER_INTERACTION_INSTRUCTIONS);
|
||||
|
||||
@@ -560,6 +587,10 @@ impl Agent {
|
||||
self.config.max_tool_result_chars
|
||||
}
|
||||
|
||||
pub fn max_concurrent_jobs(&self) -> Option<usize> {
|
||||
self.config.max_concurrent_jobs
|
||||
}
|
||||
|
||||
pub fn compression_keep_last(&self) -> Option<usize> {
|
||||
self.config.compression_keep_last
|
||||
}
|
||||
@@ -651,6 +682,10 @@ impl RoleLike for Agent {
|
||||
Some(self.config.mcp_servers.clone())
|
||||
}
|
||||
|
||||
fn mcp_tools(&self) -> Option<IndexMap<String, Vec<String>>> {
|
||||
self.config.mcp_tools.clone()
|
||||
}
|
||||
|
||||
fn set_model(&mut self, model: Model) {
|
||||
self.config.model_id = Some(model.id());
|
||||
self.model = model;
|
||||
@@ -692,6 +727,10 @@ impl RoleLike for Agent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mcp_tools(&mut self, value: Option<IndexMap<String, Vec<String>>>) {
|
||||
self.config.mcp_tools = value;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -734,6 +773,8 @@ pub struct AgentConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_tool_result_chars: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_concurrent_jobs: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub compression_keep_last: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
@@ -741,6 +782,8 @@ pub struct AgentConfig {
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||
#[serde(default)]
|
||||
pub global_tools: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -813,6 +856,7 @@ impl AgentConfig {
|
||||
description: graph.description.clone(),
|
||||
global_tools: graph.global_tools.clone(),
|
||||
mcp_servers: graph.mcp_servers.clone(),
|
||||
mcp_tools: graph.mcp_tools.clone(),
|
||||
skills_enabled: graph.skills_enabled,
|
||||
enabled_skills: graph.enabled_skills.clone(),
|
||||
inject_skill_instructions: graph.inject_skill_instructions.unwrap_or(true),
|
||||
@@ -821,6 +865,7 @@ impl AgentConfig {
|
||||
variables: graph.variables.clone(),
|
||||
can_spawn_agents: graph.has_agent_node(),
|
||||
max_concurrent_agents: default_max_concurrent_agents(),
|
||||
max_concurrent_jobs: graph.max_concurrent_jobs,
|
||||
max_agent_depth: default_max_agent_depth(),
|
||||
escalation_timeout: default_escalation_timeout(),
|
||||
..AgentConfig::default()
|
||||
@@ -1251,6 +1296,33 @@ variables:
|
||||
assert_eq!(config.enabled_macros, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_config_parses_mcp_tools() {
|
||||
let yaml =
|
||||
"name: minimal\ninstructions: hi\nmcp_tools:\n github:\n - get_*\n - list_*\n";
|
||||
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let mcp_tools = config.mcp_tools.unwrap();
|
||||
assert_eq!(
|
||||
mcp_tools.get("github"),
|
||||
Some(&vec!["get_*".to_string(), "list_*".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_mcp_tools_role_like_round_trip() {
|
||||
let config: AgentConfig =
|
||||
serde_yaml::from_str("name: minimal\ninstructions: hi\n").unwrap();
|
||||
let mut agent = Agent::test_new(config);
|
||||
assert_eq!(agent.mcp_tools(), None);
|
||||
|
||||
let mut mcp_tools = IndexMap::new();
|
||||
mcp_tools.insert("github".to_string(), vec!["get_*".to_string()]);
|
||||
agent.set_mcp_tools(Some(mcp_tools.clone()));
|
||||
|
||||
assert_eq!(agent.mcp_tools(), Some(mcp_tools));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_config_enabled_macros_empty_list_is_some_empty() {
|
||||
let yaml = "name: minimal\ninstructions: hi\nenabled_macros: []\n";
|
||||
@@ -1295,6 +1367,7 @@ variables:
|
||||
model: claude:claude-sonnet-4-6
|
||||
temperature: 0.3
|
||||
top_p: 0.8
|
||||
max_concurrent_jobs: 2
|
||||
global_tools:
|
||||
- fetch_pdf.sh
|
||||
mcp_servers:
|
||||
@@ -1317,6 +1390,7 @@ variables:
|
||||
assert_eq!(config.model_id.as_deref(), Some("claude:claude-sonnet-4-6"));
|
||||
assert_eq!(config.temperature, Some(0.3));
|
||||
assert_eq!(config.top_p, Some(0.8));
|
||||
assert_eq!(config.max_concurrent_jobs, Some(2));
|
||||
assert_eq!(config.global_tools, vec!["fetch_pdf.sh"]);
|
||||
assert_eq!(config.mcp_servers, vec!["pubmed-search"]);
|
||||
assert_eq!(config.conversation_starters, vec!["Start here"]);
|
||||
@@ -1480,4 +1554,51 @@ nodes: {}
|
||||
assert_eq!(config.top_k, Some(7));
|
||||
assert_eq!(config.embedding_model.as_deref(), Some("some:model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolated_instructions_without_job_declarations_is_byte_identical_across_job_settings() {
|
||||
let agent = |max_concurrent_jobs| {
|
||||
Agent::test_new(AgentConfig {
|
||||
instructions: "hi".to_string(),
|
||||
max_concurrent_jobs,
|
||||
..AgentConfig::default()
|
||||
})
|
||||
};
|
||||
|
||||
let baseline = agent(None).interpolated_instructions();
|
||||
assert!(
|
||||
!baseline.contains(DEFAULT_JOB_INSTRUCTIONS),
|
||||
"no job guidance may be injected without job__ declarations"
|
||||
);
|
||||
assert_eq!(baseline, agent(Some(0)).interpolated_instructions());
|
||||
assert_eq!(baseline, agent(Some(7)).interpolated_instructions());
|
||||
|
||||
let mut with_unrelated = agent(None);
|
||||
with_unrelated.functions.append_todo_functions();
|
||||
assert_eq!(
|
||||
baseline,
|
||||
with_unrelated.interpolated_instructions(),
|
||||
"job guidance injection must key strictly on the job__ prefix"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolated_instructions_with_job_declarations_appends_job_guidance() {
|
||||
let config = AgentConfig {
|
||||
instructions: "hi".to_string(),
|
||||
..AgentConfig::default()
|
||||
};
|
||||
let baseline = Agent::test_new(config.clone()).interpolated_instructions();
|
||||
|
||||
let mut agent = Agent::test_new(config);
|
||||
agent.functions.append_job_functions();
|
||||
let output = agent.interpolated_instructions();
|
||||
|
||||
assert!(output.contains(DEFAULT_JOB_INSTRUCTIONS));
|
||||
let expected = format!(
|
||||
"hi\n{DEFAULT_JOB_INSTRUCTIONS}{}",
|
||||
baseline.strip_prefix("hi").unwrap()
|
||||
);
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ pub struct AppConfig {
|
||||
pub mapping_mcp_servers: IndexMap<String, String>,
|
||||
#[serde(default, deserialize_with = "super::deserialize_csv_or_vec")]
|
||||
pub enabled_mcp_servers: Option<Vec<String>>,
|
||||
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||
|
||||
pub auto_continue: bool,
|
||||
pub max_auto_continues: usize,
|
||||
@@ -68,6 +69,7 @@ pub struct AppConfig {
|
||||
pub summarization_prompt: Option<String>,
|
||||
pub summary_context_prompt: Option<String>,
|
||||
pub max_tool_result_chars: Option<usize>,
|
||||
pub max_concurrent_jobs: Option<usize>,
|
||||
|
||||
pub memory: Option<bool>,
|
||||
pub memory_cap_with_tools: Option<usize>,
|
||||
@@ -135,6 +137,7 @@ impl Default for AppConfig {
|
||||
mcp_server_support: true,
|
||||
mapping_mcp_servers: Default::default(),
|
||||
enabled_mcp_servers: None,
|
||||
mcp_tools: None,
|
||||
|
||||
auto_continue: false,
|
||||
max_auto_continues: 10,
|
||||
@@ -153,6 +156,7 @@ impl Default for AppConfig {
|
||||
summarization_prompt: None,
|
||||
summary_context_prompt: None,
|
||||
max_tool_result_chars: None,
|
||||
max_concurrent_jobs: None,
|
||||
|
||||
memory: None,
|
||||
memory_cap_with_tools: None,
|
||||
@@ -221,6 +225,7 @@ impl AppConfig {
|
||||
mcp_server_support: config.mcp_server_support,
|
||||
mapping_mcp_servers: config.mapping_mcp_servers,
|
||||
enabled_mcp_servers: config.enabled_mcp_servers,
|
||||
mcp_tools: config.mcp_tools,
|
||||
|
||||
auto_continue: config.auto_continue,
|
||||
max_auto_continues: config.max_auto_continues,
|
||||
@@ -239,6 +244,7 @@ impl AppConfig {
|
||||
summarization_prompt: config.summarization_prompt,
|
||||
summary_context_prompt: config.summary_context_prompt,
|
||||
max_tool_result_chars: config.max_tool_result_chars,
|
||||
max_concurrent_jobs: config.max_concurrent_jobs,
|
||||
|
||||
memory: config.memory,
|
||||
memory_cap_with_tools: config.memory_cap_with_tools,
|
||||
@@ -574,6 +580,9 @@ impl AppConfig {
|
||||
{
|
||||
self.compression_threshold = v;
|
||||
}
|
||||
if let Some(v) = super::read_env_value::<usize>(&get_env_name("max_concurrent_jobs")) {
|
||||
self.max_concurrent_jobs = v;
|
||||
}
|
||||
if let Some(v) = super::read_env_value::<String>(&get_env_name("summarization_prompt")) {
|
||||
self.summarization_prompt = v;
|
||||
}
|
||||
@@ -780,6 +789,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_config_copies_mcp_tools() {
|
||||
let mut mcp_tools = IndexMap::new();
|
||||
mcp_tools.insert("github".to_string(), vec!["get_*".to_string()]);
|
||||
let cfg = Config {
|
||||
model_id: "test-model".to_string(),
|
||||
clients: vec![ClientConfig::default()],
|
||||
mcp_tools: Some(mcp_tools.clone()),
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
let app = AppConfig::from_config(cfg).unwrap();
|
||||
|
||||
assert_eq!(app.mcp_tools, Some(mcp_tools));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn from_config_copies_enabled_macros() {
|
||||
@@ -838,16 +863,59 @@ mod tests {
|
||||
|
||||
unsafe {
|
||||
match prev {
|
||||
Some(v) => std::env::set_var(&env_name, v),
|
||||
None => std::env::remove_var(&env_name),
|
||||
Some(v) => env::set_var(&env_name, v),
|
||||
None => env::remove_var(&env_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_config_copies_max_concurrent_jobs() {
|
||||
let cfg = Config {
|
||||
model_id: "test-model".to_string(),
|
||||
max_concurrent_jobs: Some(3),
|
||||
clients: vec![ClientConfig::default()],
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
let app = AppConfig::from_config(cfg).unwrap();
|
||||
|
||||
assert_eq!(app.max_concurrent_jobs, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn load_envs_overrides_max_concurrent_jobs() {
|
||||
let env_name = get_env_name("max_concurrent_jobs");
|
||||
let prev = env::var_os(&env_name);
|
||||
|
||||
let mut app = AppConfig::default();
|
||||
|
||||
unsafe { env::set_var(&env_name, "7") };
|
||||
app.load_envs();
|
||||
assert_eq!(app.max_concurrent_jobs, Some(7));
|
||||
|
||||
unsafe { env::set_var(&env_name, "0") };
|
||||
app.load_envs();
|
||||
assert_eq!(app.max_concurrent_jobs, Some(0));
|
||||
|
||||
unsafe { env::remove_var(&env_name) };
|
||||
app.max_concurrent_jobs = Some(2);
|
||||
app.load_envs();
|
||||
assert_eq!(app.max_concurrent_jobs, Some(2));
|
||||
|
||||
unsafe {
|
||||
match prev {
|
||||
Some(v) => env::set_var(&env_name, v),
|
||||
None => env::remove_var(&env_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_returns_configured_value() {
|
||||
let configured = cached_editor()
|
||||
.unwrap_or_else(|| std::env::current_exe().unwrap().display().to_string());
|
||||
let configured =
|
||||
cached_editor().unwrap_or_else(|| env::current_exe().unwrap().display().to_string());
|
||||
let app = AppConfig {
|
||||
editor: Some(configured.clone()),
|
||||
..AppConfig::default()
|
||||
@@ -864,9 +932,9 @@ mod tests {
|
||||
return;
|
||||
}
|
||||
|
||||
let expected = std::env::current_exe().unwrap().display().to_string();
|
||||
let expected = env::current_exe().unwrap().display().to_string();
|
||||
unsafe {
|
||||
std::env::set_var("VISUAL", &expected);
|
||||
env::set_var("VISUAL", &expected);
|
||||
}
|
||||
|
||||
let app = AppConfig::default();
|
||||
@@ -934,7 +1002,7 @@ mod tests {
|
||||
let app = AppConfig::from_config(cfg).unwrap();
|
||||
|
||||
let ua = app.user_agent.as_deref().unwrap();
|
||||
assert!(ua != "auto", "user_agent should have been resolved");
|
||||
assert_ne!(ua, "auto", "user_agent should have been resolved");
|
||||
assert!(ua.contains('/'), "user_agent should be '<name>/<version>'");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::mcp_factory::{McpFactory, McpServerKey};
|
||||
use super::rag_cache::RagCache;
|
||||
use crate::config::AppConfig;
|
||||
use crate::config::jobs_enabled;
|
||||
use crate::function::Functions;
|
||||
use crate::mcp::{McpRegistry, McpServersConfig};
|
||||
use crate::utils::AbortSignal;
|
||||
@@ -68,9 +69,13 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
let mut functions = Functions::init(config.visible_tools.as_ref().unwrap_or(&Vec::new()))?;
|
||||
let mut functions = Functions::init(config.visible_tools.as_deref())?;
|
||||
if !mcp_registry.is_empty() && config.mcp_server_support {
|
||||
functions.append_mcp_meta_functions(mcp_registry.list_started_servers());
|
||||
functions.append_mcp_meta_functions(mcp_registry.server_features());
|
||||
}
|
||||
|
||||
if jobs_enabled(None, &config) {
|
||||
functions.append_job_functions();
|
||||
}
|
||||
|
||||
let mcp_registry = if mcp_registry.is_empty() {
|
||||
|
||||
+66
-2
@@ -442,7 +442,9 @@ impl BundleStore {
|
||||
self.ensure_bundle_exists(bundle)?;
|
||||
for (name, record) in self.bundles.iter_mut() {
|
||||
if name != bundle {
|
||||
record.files.retain(|owned| owned.path != file.path);
|
||||
record
|
||||
.files
|
||||
.retain(|owned| !same_installed_path(&owned.path, &file.path));
|
||||
}
|
||||
}
|
||||
let record = self
|
||||
@@ -450,7 +452,9 @@ impl BundleStore {
|
||||
.get_mut(bundle)
|
||||
.expect("bundle existence checked above");
|
||||
|
||||
record.files.retain(|owned| owned.path != file.path);
|
||||
record
|
||||
.files
|
||||
.retain(|owned| !same_installed_path(&owned.path, &file.path));
|
||||
record.files.push(file);
|
||||
|
||||
self.save()
|
||||
@@ -577,6 +581,17 @@ pub(crate) struct BundleListRow {
|
||||
pub(crate) drift: DriftSummary,
|
||||
}
|
||||
|
||||
/// NTFS and default APFS resolve file names case-insensitively, so records
|
||||
/// differing only in case denote the same physical file there. Linux keeps
|
||||
/// exact matching because case variants are genuinely distinct files.
|
||||
fn same_installed_path(a: &str, b: &str) -> bool {
|
||||
if cfg!(any(windows, target_os = "macos")) {
|
||||
a.eq_ignore_ascii_case(b)
|
||||
} else {
|
||||
a == b
|
||||
}
|
||||
}
|
||||
|
||||
/// An unreadable file counts as locally modified: it exists but its integrity
|
||||
/// cannot be verified.
|
||||
pub(crate) fn bundle_list_rows(store: &BundleStore, config_dir: &Path) -> Vec<BundleListRow> {
|
||||
@@ -1489,4 +1504,53 @@ mod tests {
|
||||
|
||||
assert!(rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_installed_path_matches_filesystem_case_semantics() {
|
||||
assert!(same_installed_path("macros/a.yaml", "macros/a.yaml"));
|
||||
assert!(!same_installed_path("macros/a.yaml", "macros/b.yaml"));
|
||||
assert_eq!(
|
||||
same_installed_path("macros/Foo.yaml", "macros/foo.yaml"),
|
||||
cfg!(any(windows, target_os = "macos"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_file_transfers_case_variant_ownership_on_case_insensitive_hosts() {
|
||||
let dir = TempStoreDir::new("bundles-case-variant");
|
||||
let mut store = dir.store();
|
||||
store
|
||||
.upsert_bundle("alpha", metadata("https://x/a", "aaa"))
|
||||
.unwrap();
|
||||
store
|
||||
.upsert_bundle("beta", metadata("https://x/b", "bbb"))
|
||||
.unwrap();
|
||||
store
|
||||
.record_file("alpha", file_record("macros/Shared.yaml", "one"))
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.record_file("beta", file_record("macros/shared.yaml", "two"))
|
||||
.unwrap();
|
||||
|
||||
let alpha_still_owns = store
|
||||
.get("alpha")
|
||||
.unwrap()
|
||||
.files
|
||||
.iter()
|
||||
.any(|f| f.path == "macros/Shared.yaml");
|
||||
assert_eq!(
|
||||
alpha_still_owns,
|
||||
!cfg!(any(windows, target_os = "macos")),
|
||||
"case-variant paths are one physical file on case-insensitive hosts"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get("beta")
|
||||
.unwrap()
|
||||
.files
|
||||
.iter()
|
||||
.any(|f| f.path == "macros/shared.yaml")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+81
-1
@@ -9,7 +9,12 @@ use crate::utils::{AbortSignal, base64_encode, is_loader_protocol, sha256};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use indexmap::IndexSet;
|
||||
use std::{collections::HashMap, fs::File, io::Read, sync::Arc};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs::File,
|
||||
io::Read,
|
||||
sync::Arc,
|
||||
};
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
const IMAGE_EXTS: [&str; 5] = ["png", "jpeg", "jpg", "webp", "gif"];
|
||||
@@ -158,6 +163,13 @@ impl Input {
|
||||
self.data_urls.clone()
|
||||
}
|
||||
|
||||
pub fn declared_function_names(&self) -> HashSet<String> {
|
||||
self.functions
|
||||
.as_ref()
|
||||
.map(|functions| functions.iter().map(|f| f.name.clone()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn tool_calls(&self) -> &Option<MessageContentToolCalls> {
|
||||
&self.tool_calls
|
||||
}
|
||||
@@ -593,6 +605,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::request_context::RequestContext;
|
||||
use crate::config::{AppState, WorkingMode};
|
||||
use crate::function::ToolCall;
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
@@ -973,4 +987,70 @@ mod tests {
|
||||
));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
fn tool_result(id: &str, output: &str) -> ToolResult {
|
||||
ToolResult::new(
|
||||
ToolCall::new("t".into(), json!({}), Some(id.to_string())),
|
||||
json!(output),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_tool_results_first_merge_creates_container() {
|
||||
let ctx = create_test_ctx();
|
||||
let input = Input::from_str(&ctx, "test", None).unwrap();
|
||||
|
||||
let input =
|
||||
input.merge_tool_results("assistant text".into(), vec![tool_result("id-1", "ok")]);
|
||||
|
||||
let tool_calls = input.tool_calls().as_ref().unwrap();
|
||||
assert_eq!(tool_calls.text, "assistant text");
|
||||
assert!(!tool_calls.sequence);
|
||||
assert_eq!(tool_calls.tool_results.len(), 1);
|
||||
assert!(tool_calls.tool_results[0].text.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_tool_results_second_merge_marks_sequence_and_tags_text() {
|
||||
let ctx = create_test_ctx();
|
||||
let input = Input::from_str(&ctx, "test", None)
|
||||
.unwrap()
|
||||
.merge_tool_results("assistant text".into(), vec![tool_result("id-1", "ok")]);
|
||||
|
||||
let input =
|
||||
input.merge_tool_results("second text".into(), vec![tool_result("id-2", "ok2")]);
|
||||
|
||||
let tool_calls = input.tool_calls().as_ref().unwrap();
|
||||
assert!(tool_calls.sequence);
|
||||
assert_eq!(tool_calls.tool_results.len(), 2);
|
||||
assert_eq!(tool_calls.text, "assistant text");
|
||||
assert!(tool_calls.tool_results[0].text.is_none());
|
||||
assert_eq!(
|
||||
tool_calls.tool_results[1].text,
|
||||
Some("second text".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_messages_wraps_tool_results_in_single_assistant_message() {
|
||||
let ctx = create_test_ctx();
|
||||
let input = Input::from_str(&ctx, "test", None)
|
||||
.unwrap()
|
||||
.merge_tool_results("assistant text".into(), vec![tool_result("id-1", "ok")])
|
||||
.merge_tool_results("second text".into(), vec![tool_result("id-2", "ok2")]);
|
||||
|
||||
let messages = input.build_messages().unwrap();
|
||||
|
||||
let tool_call_messages: Vec<_> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.content, MessageContent::ToolCalls(_)))
|
||||
.collect();
|
||||
assert_eq!(tool_call_messages.len(), 1);
|
||||
let message = tool_call_messages[0];
|
||||
assert!(matches!(message.role, MessageRole::Assistant));
|
||||
let MessageContent::ToolCalls(tool_calls) = &message.content else {
|
||||
unreachable!();
|
||||
};
|
||||
assert_eq!(tool_calls.tool_results.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
+210
-23
@@ -32,7 +32,7 @@ pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool)
|
||||
if layout.is_empty() {
|
||||
println!(
|
||||
"No recognized assets found in {git_url}. Expected one or more of: \
|
||||
agents/, roles/, skills/, macros/, functions/tools/, functions/mcp.json"
|
||||
agents/, roles/, skills/, macros/, functions/tools/, mcp.json"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -752,9 +752,29 @@ fn select_uninstall_candidate(store: &BundleStore, spec: &str) -> Result<Option<
|
||||
fn is_safe_relative_path(path: &str) -> bool {
|
||||
let recorded = Path::new(path);
|
||||
!recorded.is_absolute()
|
||||
&& recorded
|
||||
.components()
|
||||
.all(|c| matches!(c, Component::Normal(_)))
|
||||
&& recorded.components().all(|c| match c {
|
||||
Component::Normal(name) => is_safe_component(&name.to_string_lossy()),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rejects names Windows refuses or silently rewrites (alternate data stream
|
||||
/// colons, reserved device names, trailing dots or spaces) so a recorded path
|
||||
/// denotes the same regular file on every platform.
|
||||
fn is_safe_component(name: &str) -> bool {
|
||||
!name.contains(':')
|
||||
&& !name.ends_with('.')
|
||||
&& !name.ends_with(' ')
|
||||
&& !is_windows_reserved_name(name)
|
||||
}
|
||||
|
||||
fn is_windows_reserved_name(name: &str) -> bool {
|
||||
let stem = name.split('.').next().unwrap_or("");
|
||||
let lower = stem.to_ascii_lowercase();
|
||||
matches!(lower.as_str(), "con" | "prn" | "aux" | "nul")
|
||||
|| (lower.len() == 4
|
||||
&& (lower.starts_with("com") || lower.starts_with("lpt"))
|
||||
&& matches!(lower.as_bytes()[3], b'1'..=b'9'))
|
||||
}
|
||||
|
||||
fn uninstall_owned_files(
|
||||
@@ -1067,7 +1087,12 @@ impl TempRepoDir {
|
||||
|
||||
impl Drop for TempRepoDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
if let Err(error) = fs::remove_dir_all(&self.path) {
|
||||
log::warn!(
|
||||
"failed to remove temp clone {}: {error}",
|
||||
self.path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1079,13 +1104,37 @@ fn is_commit_sha(reference: &str) -> bool {
|
||||
|
||||
fn clone_to_temp(url: &str, reference: Option<&str>) -> Result<TempRepoDir> {
|
||||
let dest = utils::temp_file("coyote-remote-install-", "");
|
||||
match clone_into(&dest, url, reference) {
|
||||
Ok(head_sha) => Ok(TempRepoDir {
|
||||
path: dest,
|
||||
head_sha,
|
||||
}),
|
||||
Err(error) => {
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checked-out bytes must not depend on the machine's git configuration:
|
||||
/// recorded sha256 provenance would otherwise drift with autocrlf settings.
|
||||
/// Long paths are opted into for deep bundle trees on Windows.
|
||||
fn git_content_config() -> Vec<OsString> {
|
||||
["core.autocrlf=false", "core.eol=lf", "core.longpaths=true"]
|
||||
.iter()
|
||||
.flat_map(|setting| ["-c".into(), (*setting).into()])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn clone_into(dest: &Path, url: &str, reference: Option<&str>) -> Result<String> {
|
||||
let dest_arg: OsString = dest.as_os_str().into();
|
||||
|
||||
let is_sha = reference.is_some_and(is_commit_sha);
|
||||
|
||||
match reference {
|
||||
Some(r) if !is_sha => {
|
||||
run_git(vec![
|
||||
let mut args = git_content_config();
|
||||
args.extend([
|
||||
"clone".into(),
|
||||
"--depth".into(),
|
||||
"1".into(),
|
||||
@@ -1094,26 +1143,28 @@ fn clone_to_temp(url: &str, reference: Option<&str>) -> Result<TempRepoDir> {
|
||||
"--".into(),
|
||||
url.into(),
|
||||
dest_arg,
|
||||
])?;
|
||||
]);
|
||||
run_git(args)?;
|
||||
}
|
||||
Some(r) => {
|
||||
run_git(vec![
|
||||
"clone".into(),
|
||||
"--".into(),
|
||||
url.into(),
|
||||
dest_arg.clone(),
|
||||
])?;
|
||||
run_git(vec!["-C".into(), dest_arg, "checkout".into(), r.into()])?;
|
||||
let mut args = git_content_config();
|
||||
args.extend(["clone".into(), "--".into(), url.into(), dest_arg.clone()]);
|
||||
run_git(args)?;
|
||||
let mut args = git_content_config();
|
||||
args.extend(["-C".into(), dest_arg, "checkout".into(), r.into()]);
|
||||
run_git(args)?;
|
||||
}
|
||||
None => {
|
||||
run_git(vec![
|
||||
let mut args = git_content_config();
|
||||
args.extend([
|
||||
"clone".into(),
|
||||
"--depth".into(),
|
||||
"1".into(),
|
||||
"--".into(),
|
||||
url.into(),
|
||||
dest_arg,
|
||||
])?;
|
||||
]);
|
||||
run_git(args)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,11 +1174,7 @@ fn clone_to_temp(url: &str, reference: Option<&str>) -> Result<TempRepoDir> {
|
||||
"rev-parse".into(),
|
||||
"HEAD".into(),
|
||||
])?;
|
||||
|
||||
Ok(TempRepoDir {
|
||||
path: dest,
|
||||
head_sha,
|
||||
})
|
||||
Ok(head_sha)
|
||||
}
|
||||
|
||||
fn run_git(args: Vec<OsString>) -> Result<()> {
|
||||
@@ -1216,6 +1263,11 @@ fn scan_remote_layout(root: &Path) -> Result<RemoteLayout> {
|
||||
layout.macros = Some(macros);
|
||||
}
|
||||
|
||||
let root_mcp = root.join("mcp.json");
|
||||
if root_mcp.is_file() {
|
||||
layout.mcp_json = Some(root_mcp);
|
||||
}
|
||||
|
||||
let functions = root.join("functions");
|
||||
if functions.is_dir() {
|
||||
let tools = functions.join("tools");
|
||||
@@ -1223,8 +1275,9 @@ fn scan_remote_layout(root: &Path) -> Result<RemoteLayout> {
|
||||
layout.functions_tools = Some(tools);
|
||||
}
|
||||
|
||||
// Legacy bundle layout; a root-level mcp.json wins when both exist.
|
||||
let mcp = functions.join("mcp.json");
|
||||
if mcp.is_file() {
|
||||
if layout.mcp_json.is_none() && mcp.is_file() {
|
||||
layout.mcp_json = Some(mcp);
|
||||
}
|
||||
}
|
||||
@@ -1816,7 +1869,17 @@ fn record_written_file(
|
||||
}
|
||||
|
||||
fn provenance_path(dst: &Path) -> String {
|
||||
let rel = dst.strip_prefix(paths::config_dir()).unwrap_or(dst);
|
||||
let rel = match dst.strip_prefix(paths::config_dir()) {
|
||||
Ok(rel) => rel,
|
||||
Err(_) => {
|
||||
log::warn!(
|
||||
"bundle file {} lies outside the config dir (an asset dir override?); \
|
||||
it will not be uninstallable and drift checks may misreport it",
|
||||
dst.display()
|
||||
);
|
||||
dst
|
||||
}
|
||||
};
|
||||
rel.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
@@ -2242,6 +2305,53 @@ mod tests {
|
||||
use std::env;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn safe_relative_path_accepts_plain_portable_components() {
|
||||
assert!(is_safe_relative_path("macros/a.yaml"));
|
||||
assert!(is_safe_relative_path("skills/deep/nested/file.md"));
|
||||
assert!(is_safe_relative_path("functions/tools/console.sh"));
|
||||
assert!(is_safe_relative_path("roles/common.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_relative_path_rejects_escapes_and_windows_hazards() {
|
||||
assert!(!is_safe_relative_path("../outside.yaml"));
|
||||
assert!(!is_safe_relative_path("/abs/path.yaml"));
|
||||
assert!(!is_safe_relative_path("macros/../../evil.yaml"));
|
||||
assert!(!is_safe_relative_path("macros/a.yaml:stream"));
|
||||
assert!(!is_safe_relative_path("macros/trailing."));
|
||||
assert!(!is_safe_relative_path("macros/trailing "));
|
||||
assert!(!is_safe_relative_path("macros/nul"));
|
||||
assert!(!is_safe_relative_path("macros/NUL.yaml"));
|
||||
assert!(!is_safe_relative_path("con/a.yaml"));
|
||||
assert!(!is_safe_relative_path("macros/COM1.txt"));
|
||||
assert!(!is_safe_relative_path("macros/lpt9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_reserved_name_check_is_stem_based() {
|
||||
assert!(is_windows_reserved_name("nul"));
|
||||
assert!(is_windows_reserved_name("NUL.txt"));
|
||||
assert!(is_windows_reserved_name("com1"));
|
||||
assert!(!is_windows_reserved_name("com0"));
|
||||
assert!(!is_windows_reserved_name("com10"));
|
||||
assert!(!is_windows_reserved_name("console"));
|
||||
assert!(!is_windows_reserved_name("nullable.yaml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_content_config_pins_line_endings_and_long_paths() {
|
||||
let args = git_content_config();
|
||||
let rendered: Vec<String> = args
|
||||
.iter()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(rendered.len(), 6);
|
||||
assert!(rendered.contains(&"core.autocrlf=false".to_string()));
|
||||
assert!(rendered.contains(&"core.eol=lf".to_string()));
|
||||
assert!(rendered.contains(&"core.longpaths=true".to_string()));
|
||||
}
|
||||
|
||||
struct TestVaultConfigGuard {
|
||||
dir_key: String,
|
||||
file_key: String,
|
||||
@@ -2538,6 +2648,30 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_remote_layout_finds_root_mcp_json() {
|
||||
let root = fresh_temp_dir("scan-root-mcp-test-");
|
||||
touch(&root.join("mcp.json"));
|
||||
|
||||
let layout = scan_remote_layout(&root).unwrap();
|
||||
|
||||
assert_eq!(layout.mcp_json, Some(root.join("mcp.json")));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_remote_layout_prefers_root_mcp_json_over_functions() {
|
||||
let root = fresh_temp_dir("scan-mcp-precedence-test-");
|
||||
touch(&root.join("mcp.json"));
|
||||
fs::create_dir_all(root.join("functions")).unwrap();
|
||||
touch(&root.join("functions/mcp.json"));
|
||||
|
||||
let layout = scan_remote_layout(&root).unwrap();
|
||||
|
||||
assert_eq!(layout.mcp_json, Some(root.join("mcp.json")));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_remote_layout_finds_skills_only() {
|
||||
let root = fresh_temp_dir("scan-skills-only-");
|
||||
@@ -4024,6 +4158,10 @@ mod tests {
|
||||
classify_install_target("agents", &owned_names(&["agents"])),
|
||||
InstallTarget::Category(AssetCategory::Agents)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_install_target("mcp-config", &[]),
|
||||
InstallTarget::Category(AssetCategory::McpConfig)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_install_target("mcp_config", &[]),
|
||||
InstallTarget::Category(AssetCategory::McpConfig)
|
||||
@@ -4286,6 +4424,12 @@ mod tests {
|
||||
#[test]
|
||||
#[serial]
|
||||
fn uninstall_shorthand_with_multiple_matches_bails_non_interactively() {
|
||||
if *IS_STDOUT_TERMINAL {
|
||||
eprintln!(
|
||||
"Skipping uninstall_shorthand_with_multiple_matches_bails_non_interactively: requires non-TTY stdout"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let _guard = TestVaultConfigGuard::new("uninst-short-multi");
|
||||
let mut store = BundleStore::load().unwrap();
|
||||
store
|
||||
@@ -4650,6 +4794,49 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_mcp_preserves_allowed_tools_on_surviving_entries() {
|
||||
let dir = fresh_temp_dir("uninst-mcp-allowed-tools-");
|
||||
let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
|
||||
store
|
||||
.upsert_bundle("omc", test_metadata("https://github.com/x/omc"))
|
||||
.unwrap();
|
||||
let mcp = dir.join("mcp.json");
|
||||
write_mcp(
|
||||
&mcp,
|
||||
r#"{"mcpServers": {
|
||||
"srv": {"type": "stdio", "command": "echo"},
|
||||
"user-srv": {"type": "stdio", "command": "mine", "allowedTools": ["get_*", "list_issues"]}
|
||||
}}"#,
|
||||
);
|
||||
let parsed: McpServersConfig =
|
||||
serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap();
|
||||
let hash = hash_bytes(
|
||||
serde_json::to_string(parsed.mcp_servers.get("srv").unwrap())
|
||||
.unwrap()
|
||||
.as_bytes(),
|
||||
);
|
||||
store
|
||||
.record_mcp_servers(
|
||||
"omc",
|
||||
vec![mcp_server_record("srv", McpAction::Added, Some(hash))],
|
||||
)
|
||||
.unwrap();
|
||||
let servers = store.get("omc").unwrap().mcp_servers.clone();
|
||||
|
||||
let summary = uninstall_mcp_entries(&mut store, "omc", &servers, &mcp, true).unwrap();
|
||||
|
||||
assert_eq!(summary.removed, vec!["srv"]);
|
||||
let raw = fs::read_to_string(&mcp).unwrap();
|
||||
assert!(raw.contains("allowedTools"));
|
||||
let written: McpServersConfig = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(
|
||||
written.mcp_servers.get("user-srv").unwrap().allowed_tools,
|
||||
Some(vec!["get_*".to_string(), "list_issues".to_string()])
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_mcp_reports_referenced_secrets_without_removing_them() {
|
||||
let dir = fresh_temp_dir("uninst-mcp-secrets-");
|
||||
|
||||
@@ -287,6 +287,7 @@ fn discover_macros_in(dirs: &[(MacroSource, PathBuf)]) -> Vec<DiscoveredMacro> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repl;
|
||||
use crate::utils::get_env_name;
|
||||
use serial_test::serial;
|
||||
use std::path::Path;
|
||||
@@ -565,6 +566,34 @@ mod tests {
|
||||
assert_eq!(state_of(&policy, "a"), &MacroState::Enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_macro_is_shadowed_by_builtin() {
|
||||
let policy = MacroPolicy::effective_with(
|
||||
globals(&["prompt"]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&repl::builtin_command_names(),
|
||||
);
|
||||
|
||||
assert_eq!(state_of(&policy, "prompt"), &MacroState::ShadowedBuiltin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temp_role_macro_is_shadowed_by_builtin() {
|
||||
let policy = MacroPolicy::effective_with(
|
||||
globals(&["temp-role"]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&repl::builtin_command_names(),
|
||||
);
|
||||
|
||||
assert_eq!(state_of(&policy, "temp-role"), &MacroState::ShadowedBuiltin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_wins_over_shadowed_builtin() {
|
||||
let l = list(&["a"]);
|
||||
|
||||
@@ -139,6 +139,7 @@ mod tests {
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +157,7 @@ mod tests {
|
||||
url: Some(url.to_string()),
|
||||
headers,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,859 @@
|
||||
use crate::mcp::McpServersConfig;
|
||||
|
||||
use fancy_regex::Regex;
|
||||
use indexmap::IndexMap;
|
||||
use log::warn;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
/// The configuration level that contributed a layer of tool patterns for an
|
||||
/// MCP server, as rendered in diagnostics.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LayerSource {
|
||||
Global,
|
||||
AppConfig,
|
||||
Role(String),
|
||||
Agent(String),
|
||||
Session,
|
||||
Skill(String),
|
||||
Node(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for LayerSource {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
LayerSource::Global => write!(f, "global (mcp.json)"),
|
||||
LayerSource::AppConfig => write!(f, "config (config.yaml)"),
|
||||
LayerSource::Role(name) => write!(f, "role ({name})"),
|
||||
LayerSource::Agent(name) => write!(f, "agent ({name})"),
|
||||
LayerSource::Session => write!(f, "session (.set)"),
|
||||
LayerSource::Skill(name) => write!(f, "skill ({name})"),
|
||||
LayerSource::Node(id) => write!(f, "node ({id})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LayerSource {
|
||||
pub fn short_label(&self) -> &'static str {
|
||||
match self {
|
||||
LayerSource::Global => "global",
|
||||
LayerSource::AppConfig => "config",
|
||||
LayerSource::Role(_) => "role",
|
||||
LayerSource::Agent(_) => "agent",
|
||||
LayerSource::Session => "session",
|
||||
LayerSource::Skill(_) => "skill",
|
||||
LayerSource::Node(_) => "node",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledPatterns {
|
||||
source: LayerSource,
|
||||
raw: Vec<String>,
|
||||
regexes: Vec<Regex>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ToolFilter {
|
||||
layers: Vec<CompiledPatterns>,
|
||||
}
|
||||
|
||||
impl ToolFilter {
|
||||
pub fn push_layer(&mut self, source: LayerSource, patterns: &[String]) {
|
||||
self.layers.push(CompiledPatterns {
|
||||
source,
|
||||
raw: patterns.to_vec(),
|
||||
regexes: patterns.iter().map(|p| compile_glob(p)).collect(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn layers(&self) -> impl Iterator<Item = (&LayerSource, &[String])> {
|
||||
self.layers
|
||||
.iter()
|
||||
.map(|layer| (&layer.source, layer.raw.as_slice()))
|
||||
}
|
||||
|
||||
pub fn allows(&self, tool: &str) -> bool {
|
||||
self.layers.iter().all(|layer| {
|
||||
layer
|
||||
.regexes
|
||||
.iter()
|
||||
.any(|regex| regex.is_match(tool).unwrap_or(false))
|
||||
})
|
||||
}
|
||||
|
||||
/// The first matching raw pattern per layer, in layer order, or the
|
||||
/// source of the first layer with no match.
|
||||
pub fn allows_explain(&self, tool: &str) -> Result<Vec<(&LayerSource, &str)>, &LayerSource> {
|
||||
let mut matched = Vec::with_capacity(self.layers.len());
|
||||
for layer in &self.layers {
|
||||
// fancy_regex can fail at match time (backtracking limits);
|
||||
// treat that as a non-match rather than allowing the tool.
|
||||
match layer
|
||||
.regexes
|
||||
.iter()
|
||||
.position(|regex| regex.is_match(tool).unwrap_or(false))
|
||||
{
|
||||
Some(index) => matched.push((&layer.source, layer.raw[index].as_str())),
|
||||
None => return Err(&layer.source),
|
||||
}
|
||||
}
|
||||
Ok(matched)
|
||||
}
|
||||
|
||||
pub fn dead_context_patterns(&self, advertised: &[String]) -> Vec<(&LayerSource, &str)> {
|
||||
let surviving: Vec<&String> = advertised
|
||||
.iter()
|
||||
.filter(|name| {
|
||||
self.layers
|
||||
.iter()
|
||||
.filter(|layer| layer.source == LayerSource::Global)
|
||||
.all(|layer| {
|
||||
layer
|
||||
.regexes
|
||||
.iter()
|
||||
.any(|regex| regex.is_match(name).unwrap_or(false))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut dead = Vec::new();
|
||||
for layer in self
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.source != LayerSource::Global)
|
||||
{
|
||||
for (raw, regex) in layer.raw.iter().zip(&layer.regexes) {
|
||||
if !surviving
|
||||
.iter()
|
||||
.any(|name| regex.is_match(name).unwrap_or(false))
|
||||
{
|
||||
dead.push((&layer.source, raw.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dead
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates a glob pattern (`*` = any run of characters, `?` = exactly one)
|
||||
/// into an anchored regex. Patterns that fail to compile match nothing.
|
||||
fn compile_glob(pattern: &str) -> Regex {
|
||||
let translated = format!(
|
||||
"^{}$",
|
||||
fancy_regex::escape(pattern)
|
||||
.replace("\\*", ".*")
|
||||
.replace("\\?", ".")
|
||||
);
|
||||
Regex::new(&translated).unwrap_or_else(|error| {
|
||||
warn!("Invalid MCP tool pattern '{pattern}': {error}. It will match nothing.");
|
||||
never_matching_regex()
|
||||
})
|
||||
}
|
||||
|
||||
fn never_matching_regex() -> Regex {
|
||||
Regex::new("(?!)").expect("'(?!)' is a valid never-matching regex")
|
||||
}
|
||||
|
||||
pub struct SkillMcpLayer {
|
||||
pub name: String,
|
||||
pub enabled_servers: Vec<String>,
|
||||
pub mcp_tools: IndexMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
pub struct McpToolPolicy;
|
||||
|
||||
impl McpToolPolicy {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn effective(
|
||||
mcp_config: &McpServersConfig,
|
||||
session: Option<&IndexMap<String, Vec<String>>>,
|
||||
agent: Option<(&str, &IndexMap<String, Vec<String>>)>,
|
||||
role: Option<(&str, &IndexMap<String, Vec<String>>)>,
|
||||
global: Option<&IndexMap<String, Vec<String>>>,
|
||||
skills: &[SkillMcpLayer],
|
||||
node: Option<(&str, &IndexMap<String, Vec<String>>)>,
|
||||
aliases: &IndexMap<String, String>,
|
||||
) -> HashMap<String, ToolFilter> {
|
||||
let mut filters: HashMap<String, ToolFilter> = HashMap::new();
|
||||
|
||||
for (server, spec) in &mcp_config.mcp_servers {
|
||||
if let Some(patterns) = &spec.allowed_tools {
|
||||
filters
|
||||
.entry(server.clone())
|
||||
.or_default()
|
||||
.push_layer(LayerSource::Global, patterns);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(map) = global {
|
||||
push_level(
|
||||
&mut filters,
|
||||
mcp_config,
|
||||
aliases,
|
||||
&LayerSource::AppConfig,
|
||||
map,
|
||||
None,
|
||||
);
|
||||
}
|
||||
if let Some((name, map)) = role {
|
||||
push_level(
|
||||
&mut filters,
|
||||
mcp_config,
|
||||
aliases,
|
||||
&LayerSource::Role(name.to_string()),
|
||||
map,
|
||||
None,
|
||||
);
|
||||
}
|
||||
if let Some((name, map)) = agent {
|
||||
push_level(
|
||||
&mut filters,
|
||||
mcp_config,
|
||||
aliases,
|
||||
&LayerSource::Agent(name.to_string()),
|
||||
map,
|
||||
None,
|
||||
);
|
||||
}
|
||||
if let Some(map) = session {
|
||||
push_level(
|
||||
&mut filters,
|
||||
mcp_config,
|
||||
aliases,
|
||||
&LayerSource::Session,
|
||||
map,
|
||||
None,
|
||||
);
|
||||
}
|
||||
for skill in skills {
|
||||
push_level(
|
||||
&mut filters,
|
||||
mcp_config,
|
||||
aliases,
|
||||
&LayerSource::Skill(skill.name.clone()),
|
||||
&skill.mcp_tools,
|
||||
Some(&skill.enabled_servers),
|
||||
);
|
||||
}
|
||||
if let Some((id, map)) = node {
|
||||
push_level(
|
||||
&mut filters,
|
||||
mcp_config,
|
||||
aliases,
|
||||
&LayerSource::Node(id.to_string()),
|
||||
map,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
filters
|
||||
}
|
||||
}
|
||||
|
||||
fn push_level(
|
||||
filters: &mut HashMap<String, ToolFilter>,
|
||||
mcp_config: &McpServersConfig,
|
||||
aliases: &IndexMap<String, String>,
|
||||
source: &LayerSource,
|
||||
map: &IndexMap<String, Vec<String>>,
|
||||
enabled_servers: Option<&[String]>,
|
||||
) {
|
||||
for (server, patterns) in expand_server_keys(mcp_config, aliases, map) {
|
||||
if let Some(enabled) = enabled_servers
|
||||
&& !enabled.iter().any(|id| id == &server)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
filters
|
||||
.entry(server)
|
||||
.or_default()
|
||||
.push_layer(source.clone(), &patterns);
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_server_keys(
|
||||
mcp_config: &McpServersConfig,
|
||||
aliases: &IndexMap<String, String>,
|
||||
map: &IndexMap<String, Vec<String>>,
|
||||
) -> IndexMap<String, Vec<String>> {
|
||||
let mut expanded: IndexMap<String, Vec<String>> = IndexMap::new();
|
||||
for (key, patterns) in map {
|
||||
let key = key.trim();
|
||||
if mcp_config.mcp_servers.contains_key(key) {
|
||||
expanded
|
||||
.entry(key.to_string())
|
||||
.or_default()
|
||||
.extend(patterns.iter().cloned());
|
||||
} else {
|
||||
for mapped_id in expand_mcp_server_alias(aliases, key) {
|
||||
if mcp_config.mcp_servers.contains_key(&mapped_id) {
|
||||
expanded
|
||||
.entry(mapped_id)
|
||||
.or_default()
|
||||
.extend(patterns.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expanded
|
||||
}
|
||||
|
||||
pub(crate) fn expand_mcp_server_alias(
|
||||
aliases: &IndexMap<String, String>,
|
||||
key: &str,
|
||||
) -> Vec<String> {
|
||||
aliases
|
||||
.get(key)
|
||||
.map(|mapped| {
|
||||
mapped
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mcp::{McpServer, McpServersConfig, McpTransportType};
|
||||
|
||||
fn spec(allowed_tools: Option<&[&str]>) -> McpServer {
|
||||
McpServer {
|
||||
transport_type: McpTransportType::Stdio,
|
||||
command: Some("echo".to_string()),
|
||||
args: None,
|
||||
env: None,
|
||||
cwd: None,
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: allowed_tools.map(list),
|
||||
}
|
||||
}
|
||||
|
||||
fn config(servers: &[(&str, Option<&[&str]>)]) -> McpServersConfig {
|
||||
McpServersConfig {
|
||||
mcp_servers: servers
|
||||
.iter()
|
||||
.map(|(name, tools)| (name.to_string(), spec(*tools)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn list(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
fn tool_map(entries: &[(&str, &[&str])]) -> IndexMap<String, Vec<String>> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(server, patterns)| (server.to_string(), list(patterns)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn single_layer(patterns: &[&str]) -> ToolFilter {
|
||||
layered(&[(LayerSource::Global, patterns)])
|
||||
}
|
||||
|
||||
fn layered(layers: &[(LayerSource, &[&str])]) -> ToolFilter {
|
||||
let mut filter = ToolFilter::default();
|
||||
for (source, patterns) in layers {
|
||||
filter.push_layer(source.clone(), &list(patterns));
|
||||
}
|
||||
filter
|
||||
}
|
||||
|
||||
fn no_aliases() -> IndexMap<String, String> {
|
||||
IndexMap::new()
|
||||
}
|
||||
|
||||
fn aliases(entries: &[(&str, &str)]) -> IndexMap<String, String> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(key, value)| (key.to_string(), value.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
config: &McpServersConfig,
|
||||
session: Option<&IndexMap<String, Vec<String>>>,
|
||||
role: Option<(&str, &IndexMap<String, Vec<String>>)>,
|
||||
) -> HashMap<String, ToolFilter> {
|
||||
McpToolPolicy::effective(config, session, None, role, None, &[], None, &no_aliases())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_pattern_matches_only_the_exact_name() {
|
||||
let filter = single_layer(&["get_issue"]);
|
||||
|
||||
assert!(filter.allows("get_issue"));
|
||||
assert!(!filter.allows("get_issues"));
|
||||
assert!(!filter.allows("get_issu"));
|
||||
assert!(!filter.allows("xget_issue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn star_matches_any_run_of_characters() {
|
||||
let filter = single_layer(&["get_*"]);
|
||||
assert!(filter.allows("get_issue"));
|
||||
assert!(filter.allows("get_"));
|
||||
assert!(!filter.allows("set_issue"));
|
||||
|
||||
let filter = single_layer(&["*_issue"]);
|
||||
assert!(filter.allows("create_issue"));
|
||||
assert!(!filter.allows("create_pr"));
|
||||
|
||||
let filter = single_layer(&["get*sue"]);
|
||||
assert!(filter.allows("get_issue"));
|
||||
assert!(filter.allows("getsue"));
|
||||
|
||||
let filter = single_layer(&["*"]);
|
||||
assert!(filter.allows(""));
|
||||
assert!(filter.allows("anything_at_all"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_mark_matches_exactly_one_character() {
|
||||
let filter = single_layer(&["get_?"]);
|
||||
|
||||
assert!(filter.allows("get_a"));
|
||||
assert!(!filter.allows("get_"));
|
||||
assert!(!filter.allows("get_ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_metacharacters_are_matched_literally() {
|
||||
let filter = single_layer(&["get.issue"]);
|
||||
assert!(filter.allows("get.issue"));
|
||||
assert!(!filter.allows("getXissue"));
|
||||
|
||||
for pattern in ["a(b", "a[b", "a+b", "a|b", "a$b"] {
|
||||
let filter = single_layer(&[pattern]);
|
||||
assert!(filter.allows(pattern), "'{pattern}' should match itself");
|
||||
assert!(!filter.allows("ab"), "'{pattern}' should not match 'ab'");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backslash_is_literal_and_star_still_wildcards() {
|
||||
let filter = single_layer(&["a\\b"]);
|
||||
assert!(filter.allows("a\\b"));
|
||||
assert!(!filter.allows("ab"));
|
||||
|
||||
let filter = single_layer(&["a\\*b"]);
|
||||
assert!(filter.allows("a\\b"));
|
||||
assert!(filter.allows("a\\xyzb"));
|
||||
assert!(!filter.allows("ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_never_matching_placeholder_matches_nothing() {
|
||||
let regex = never_matching_regex();
|
||||
|
||||
assert!(!regex.is_match("").unwrap());
|
||||
assert!(!regex.is_match("anything").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn within_a_layer_any_pattern_may_match() {
|
||||
let filter = single_layer(&["get_*", "set_*"]);
|
||||
|
||||
assert!(filter.allows("get_x"));
|
||||
assert!(filter.allows("set_x"));
|
||||
assert!(!filter.allows("delete_x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn across_layers_every_layer_must_match() {
|
||||
let filter = layered(&[
|
||||
(LayerSource::Global, &["get_*"]),
|
||||
(LayerSource::Session, &["*_issue"]),
|
||||
]);
|
||||
|
||||
assert!(filter.allows("get_issue"));
|
||||
assert!(!filter.allows("get_pr"));
|
||||
assert!(!filter.allows("create_issue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_layer_blocks_everything() {
|
||||
let filter = layered(&[(LayerSource::Global, &["*"]), (LayerSource::Session, &[])]);
|
||||
|
||||
assert!(!filter.allows("anything"));
|
||||
assert_eq!(
|
||||
filter.allows_explain("anything"),
|
||||
Err(&LayerSource::Session)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_explain_reports_the_first_matching_pattern_per_layer() {
|
||||
let filter = layered(&[
|
||||
(LayerSource::Global, &["x_*", "get_*"]),
|
||||
(LayerSource::Session, &["*"]),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
filter.allows_explain("get_issue").unwrap(),
|
||||
vec![
|
||||
(&LayerSource::Global, "get_*"),
|
||||
(&LayerSource::Session, "*")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_explain_reports_the_first_layer_without_a_match() {
|
||||
let filter = layered(&[
|
||||
(LayerSource::Global, &["get_*"]),
|
||||
(LayerSource::Session, &["*"]),
|
||||
]);
|
||||
assert_eq!(
|
||||
filter.allows_explain("delete_repo"),
|
||||
Err(&LayerSource::Global)
|
||||
);
|
||||
|
||||
let filter = layered(&[
|
||||
(LayerSource::Global, &["*"]),
|
||||
(LayerSource::Session, &["get_*"]),
|
||||
]);
|
||||
assert_eq!(
|
||||
filter.allows_explain("delete_repo"),
|
||||
Err(&LayerSource::Session)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_allowed_tools_from_mcp_json_is_the_first_layer() {
|
||||
let config = config(&[("gh", Some(&["get_*"]))]);
|
||||
let session_map = tool_map(&[("gh", &["*"])]);
|
||||
|
||||
let filters = resolve(&config, Some(&session_map), None);
|
||||
|
||||
assert_eq!(
|
||||
filters["gh"].allows_explain("get_issue").unwrap(),
|
||||
vec![
|
||||
(&LayerSource::Global, "get_*"),
|
||||
(&LayerSource::Session, "*")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn servers_without_patterns_at_any_level_are_absent() {
|
||||
let config = config(&[("gh", None)]);
|
||||
|
||||
let filters = resolve(&config, None, None);
|
||||
|
||||
assert!(filters.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_absent_from_a_level_map_gets_no_layer_from_it() {
|
||||
let config = config(&[("gh", Some(&["get_*"])), ("gl", None)]);
|
||||
let role_map = tool_map(&[("gl", &["x_*"])]);
|
||||
|
||||
let filters = resolve(&config, None, Some(("dev", &role_map)));
|
||||
|
||||
assert!(filters["gh"].allows("get_issue"));
|
||||
assert!(!filters["gh"].allows("delete_repo"));
|
||||
assert_eq!(filters["gh"].allows_explain("get_issue").unwrap().len(), 1);
|
||||
assert!(filters["gl"].allows("x_1"));
|
||||
assert!(!filters["gl"].allows("y_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pattern_list_at_a_level_blocks_all_tools_for_that_server() {
|
||||
let config = config(&[("gh", Some(&["get_*"]))]);
|
||||
let session_map = tool_map(&[("gh", &[])]);
|
||||
|
||||
let filters = resolve(&config, Some(&session_map), None);
|
||||
|
||||
assert!(!filters["gh"].allows("get_issue"));
|
||||
assert_eq!(
|
||||
filters["gh"].allows_explain("get_issue"),
|
||||
Err(&LayerSource::Session)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_cannot_widen_a_role_restriction() {
|
||||
let config = config(&[("gh", None)]);
|
||||
let role_map = tool_map(&[("gh", &["get_*"])]);
|
||||
let session_map = tool_map(&[("gh", &["*"])]);
|
||||
|
||||
let filters = resolve(&config, Some(&session_map), Some(("dev", &role_map)));
|
||||
|
||||
assert!(filters["gh"].allows("get_issue"));
|
||||
assert!(!filters["gh"].allows("delete_repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_config_map_contributes_its_own_layer() {
|
||||
let config = config(&[("gh", None)]);
|
||||
let app_map = tool_map(&[("gh", &["get_*"])]);
|
||||
|
||||
let filters = McpToolPolicy::effective(
|
||||
&config,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&app_map),
|
||||
&[],
|
||||
None,
|
||||
&no_aliases(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
filters["gh"].allows_explain("get_issue").unwrap(),
|
||||
vec![(&LayerSource::AppConfig, "get_*")]
|
||||
);
|
||||
assert!(!filters["gh"].allows("delete_repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_layer_applies_only_to_its_enabled_servers() {
|
||||
let config = config(&[("gh", None), ("gl", None)]);
|
||||
let skill = SkillMcpLayer {
|
||||
name: "reviewer".to_string(),
|
||||
enabled_servers: vec!["gh".to_string()],
|
||||
mcp_tools: tool_map(&[("gh", &["get_*"]), ("gl", &["*"])]),
|
||||
};
|
||||
|
||||
let filters = McpToolPolicy::effective(
|
||||
&config,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[skill],
|
||||
None,
|
||||
&no_aliases(),
|
||||
);
|
||||
|
||||
assert!(filters.contains_key("gh"));
|
||||
assert!(!filters.contains_key("gl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_skills_naming_the_same_server_stack_independent_layers() {
|
||||
let config = config(&[("gh", None)]);
|
||||
let skills = vec![
|
||||
SkillMcpLayer {
|
||||
name: "a".to_string(),
|
||||
enabled_servers: vec!["gh".to_string()],
|
||||
mcp_tools: tool_map(&[("gh", &["get_*"])]),
|
||||
},
|
||||
SkillMcpLayer {
|
||||
name: "b".to_string(),
|
||||
enabled_servers: vec!["gh".to_string()],
|
||||
mcp_tools: tool_map(&[("gh", &["*_issue"])]),
|
||||
},
|
||||
];
|
||||
|
||||
let filters = McpToolPolicy::effective(
|
||||
&config,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&skills,
|
||||
None,
|
||||
&no_aliases(),
|
||||
);
|
||||
|
||||
assert!(filters["gh"].allows("get_issue"));
|
||||
assert!(!filters["gh"].allows("get_pr"));
|
||||
assert!(!filters["gh"].allows("create_issue"));
|
||||
assert_eq!(filters["gh"].allows_explain("get_issue").unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_stack_in_documented_order_with_node_last() {
|
||||
let config = config(&[("gh", Some(&["*"]))]);
|
||||
let app_map = tool_map(&[("gh", &["*"])]);
|
||||
let role_map = tool_map(&[("gh", &["*"])]);
|
||||
let agent_map = tool_map(&[("gh", &["*"])]);
|
||||
let session_map = tool_map(&[("gh", &["*"])]);
|
||||
let skills = vec![SkillMcpLayer {
|
||||
name: "reviewer".to_string(),
|
||||
enabled_servers: vec!["gh".to_string()],
|
||||
mcp_tools: tool_map(&[("gh", &["*"])]),
|
||||
}];
|
||||
let node_map = tool_map(&[("gh", &["*"])]);
|
||||
|
||||
let filters = McpToolPolicy::effective(
|
||||
&config,
|
||||
Some(&session_map),
|
||||
Some(("worker", &agent_map)),
|
||||
Some(("dev", &role_map)),
|
||||
Some(&app_map),
|
||||
&skills,
|
||||
Some(("n1", &node_map)),
|
||||
&no_aliases(),
|
||||
);
|
||||
|
||||
let sources: Vec<String> = filters["gh"]
|
||||
.allows_explain("anything")
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(source, _)| source.to_string())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
sources,
|
||||
vec![
|
||||
"global (mcp.json)",
|
||||
"config (config.yaml)",
|
||||
"role (dev)",
|
||||
"agent (worker)",
|
||||
"session (.set)",
|
||||
"skill (reviewer)",
|
||||
"node (n1)",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_key_expands_to_all_mapped_servers() {
|
||||
let config = config(&[("github", None), ("gitlab", None)]);
|
||||
let role_map = tool_map(&[("gh", &["get_*"])]);
|
||||
|
||||
let filters = McpToolPolicy::effective(
|
||||
&config,
|
||||
None,
|
||||
None,
|
||||
Some(("dev", &role_map)),
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
&aliases(&[("gh", "github,gitlab")]),
|
||||
);
|
||||
|
||||
assert!(filters["github"].allows("get_issue"));
|
||||
assert!(!filters["github"].allows("delete_repo"));
|
||||
assert!(filters["gitlab"].allows("get_issue"));
|
||||
assert!(!filters["gitlab"].allows("delete_repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_ids_missing_from_the_config_are_skipped() {
|
||||
let config = config(&[("github", None)]);
|
||||
let role_map = tool_map(&[("gh", &["get_*"])]);
|
||||
|
||||
let filters = McpToolPolicy::effective(
|
||||
&config,
|
||||
None,
|
||||
None,
|
||||
Some(("dev", &role_map)),
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
&aliases(&[("gh", "github,missing")]),
|
||||
);
|
||||
|
||||
assert_eq!(filters.len(), 1);
|
||||
assert!(filters.contains_key("github"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_map_keys_are_dropped() {
|
||||
let config = config(&[("github", None)]);
|
||||
let role_map = tool_map(&[("nope", &["get_*"])]);
|
||||
|
||||
let filters = resolve(&config, None, Some(("dev", &role_map)));
|
||||
|
||||
assert!(filters.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_and_direct_key_for_the_same_server_merge_into_one_layer() {
|
||||
let config = config(&[("github", None)]);
|
||||
let role_map = tool_map(&[("gh", &["get_*"]), ("github", &["set_*"])]);
|
||||
|
||||
let filters = McpToolPolicy::effective(
|
||||
&config,
|
||||
None,
|
||||
None,
|
||||
Some(("dev", &role_map)),
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
&aliases(&[("gh", "github")]),
|
||||
);
|
||||
|
||||
assert!(filters["github"].allows("get_issue"));
|
||||
assert!(filters["github"].allows("set_topic"));
|
||||
assert!(!filters["github"].allows("delete_repo"));
|
||||
assert_eq!(
|
||||
filters["github"].allows_explain("get_issue").unwrap().len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_source_display() {
|
||||
assert_eq!(LayerSource::Global.to_string(), "global (mcp.json)");
|
||||
assert_eq!(LayerSource::AppConfig.to_string(), "config (config.yaml)");
|
||||
assert_eq!(LayerSource::Role("dev".into()).to_string(), "role (dev)");
|
||||
assert_eq!(
|
||||
LayerSource::Agent("worker".into()).to_string(),
|
||||
"agent (worker)"
|
||||
);
|
||||
assert_eq!(LayerSource::Session.to_string(), "session (.set)");
|
||||
assert_eq!(
|
||||
LayerSource::Skill("review".into()).to_string(),
|
||||
"skill (review)"
|
||||
);
|
||||
assert_eq!(LayerSource::Node("n1".into()).to_string(), "node (n1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_context_patterns_flags_patterns_matching_nothing() {
|
||||
let filter = layered(&[
|
||||
(LayerSource::Global, &["get_*"]),
|
||||
(LayerSource::Role("dev".into()), &["get_issue", "set_*"]),
|
||||
]);
|
||||
|
||||
let advertised = vec!["get_issue".to_string(), "set_topic".to_string()];
|
||||
let dead = filter.dead_context_patterns(&advertised);
|
||||
|
||||
// set_* only matches set_topic, which the global layer hides.
|
||||
assert_eq!(dead, vec![(&LayerSource::Role("dev".into()), "set_*")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_context_patterns_is_empty_when_every_pattern_is_live() {
|
||||
let filter = layered(&[
|
||||
(LayerSource::Global, &["get_*"]),
|
||||
(LayerSource::Session, &["get_issue"]),
|
||||
]);
|
||||
|
||||
let advertised = vec!["get_issue".to_string()];
|
||||
assert!(filter.dead_context_patterns(&advertised).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_context_patterns_ignores_the_global_layer_itself() {
|
||||
let filter = layered(&[(LayerSource::Global, &["zzz_*"])]);
|
||||
|
||||
let advertised = vec!["get_issue".to_string()];
|
||||
assert!(filter.dead_context_patterns(&advertised).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_mcp_server_alias_splits_and_trims() {
|
||||
let aliases = aliases(&[("gh", "github, gitlab,")]);
|
||||
|
||||
assert_eq!(
|
||||
expand_mcp_server_alias(&aliases, "gh"),
|
||||
vec!["github".to_string(), "gitlab".to_string()]
|
||||
);
|
||||
assert!(expand_mcp_server_alias(&aliases, "nope").is_empty());
|
||||
}
|
||||
}
|
||||
+158
-90
@@ -8,6 +8,7 @@ pub(crate) mod instructions;
|
||||
mod macro_policy;
|
||||
mod macros;
|
||||
mod mcp_factory;
|
||||
mod mcp_tool_policy;
|
||||
pub(crate) mod memory;
|
||||
pub(crate) mod paths;
|
||||
pub(crate) mod prompts;
|
||||
@@ -22,6 +23,8 @@ pub(crate) mod todo;
|
||||
mod tool_scope;
|
||||
mod update;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::agent::AgentConfig;
|
||||
pub use self::agent::{
|
||||
Agent, AgentVariable, AgentVariables, complete_agent_variables, list_agents,
|
||||
list_agents_with_descriptions,
|
||||
@@ -39,8 +42,14 @@ pub use self::install_remote::{
|
||||
pub use self::macro_policy::{
|
||||
MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro,
|
||||
};
|
||||
pub(crate) use self::mcp_tool_policy::expand_mcp_server_alias;
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::mcp_tool_policy::{LayerSource, ToolFilter};
|
||||
#[allow(unused_imports)]
|
||||
pub use self::request_context::{RenderMode, RequestContext, should_inject_skill_instructions};
|
||||
pub use self::request_context::{
|
||||
RenderMode, RequestContext, effective_max_concurrent_jobs, jobs_enabled,
|
||||
should_inject_skill_instructions,
|
||||
};
|
||||
pub use self::role::{
|
||||
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
|
||||
};
|
||||
@@ -51,6 +60,12 @@ pub use self::skill::Skill;
|
||||
pub use self::skill_policy::SkillPolicy;
|
||||
#[allow(unused_imports)]
|
||||
pub use self::skill_registry::SkillRegistry;
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::tool_scope::test_fixtures;
|
||||
pub use self::tool_scope::{
|
||||
McpPromptCompletion, McpRuntime, flatten_prompt_messages, resolve_prompt_args,
|
||||
sanitize_display_text,
|
||||
};
|
||||
pub use self::update::run_self_update;
|
||||
use crate::client::{
|
||||
self, ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
|
||||
@@ -135,6 +150,9 @@ fn validate_no_template_in_secrets_provider(content: &str) -> Result<()> {
|
||||
const DARK_THEME: &[u8] = include_bytes!("../../assets/monokai-extended.theme.bin");
|
||||
const LIGHT_THEME: &[u8] = include_bytes!("../../assets/monokai-extended-light.theme.bin");
|
||||
|
||||
/// Fully documented config skeleton the first-run wizard splices dynamic values into.
|
||||
const CONFIG_TEMPLATE: &str = include_str!("../../assets/config-template.yaml");
|
||||
|
||||
const CONFIG_FILE_NAME: &str = "config.yaml";
|
||||
const AGENT_GRAPH_FILE_NAME: &str = "graph.yaml";
|
||||
const ROLES_DIR_NAME: &str = "roles";
|
||||
@@ -165,27 +183,6 @@ const BUNDLE_MANIFEST_FILE: &str = "coyote-bundle.yaml";
|
||||
const SBX_MIXIN_KITS_DIR_NAME: &str = "sbx-mixin-kits";
|
||||
const GIT_DIR_NAME: &str = ".git";
|
||||
const GITIGNORE_FILE_NAME: &str = ".gitignore";
|
||||
const DEFAULT_VISIBLE_TOOLS: [&str; 19] = [
|
||||
"execute_command.sh",
|
||||
"execute_py_code.py",
|
||||
"execute_sql_code.sh",
|
||||
"fetch_url_via_curl.sh",
|
||||
"fs_cat.sh",
|
||||
"fs_glob.sh",
|
||||
"fs_grep.sh",
|
||||
"fs_ls.sh",
|
||||
"fs_mkdir.sh",
|
||||
"fs_patch.sh",
|
||||
"fs_read.sh",
|
||||
"fs_rm.sh",
|
||||
"fs_write.sh",
|
||||
"ast_grep.sh",
|
||||
"get_current_time.sh",
|
||||
"get_current_weather.sh",
|
||||
"search_wikipedia.sh",
|
||||
"search_arxiv.sh",
|
||||
"web_search_coyote.sh",
|
||||
];
|
||||
|
||||
const CLIENTS_FIELD: &str = "clients";
|
||||
|
||||
@@ -239,6 +236,7 @@ pub struct Config {
|
||||
pub mapping_mcp_servers: IndexMap<String, String>,
|
||||
#[serde(default, deserialize_with = "deserialize_csv_or_vec")]
|
||||
pub enabled_mcp_servers: Option<Vec<String>>,
|
||||
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||
|
||||
pub auto_continue: bool,
|
||||
pub max_auto_continues: usize,
|
||||
@@ -257,6 +255,7 @@ pub struct Config {
|
||||
pub summarization_prompt: Option<String>,
|
||||
pub summary_context_prompt: Option<String>,
|
||||
pub max_tool_result_chars: Option<usize>,
|
||||
pub max_concurrent_jobs: Option<usize>,
|
||||
|
||||
pub memory: Option<bool>,
|
||||
pub memory_cap_with_tools: Option<usize>,
|
||||
@@ -321,6 +320,7 @@ impl Default for Config {
|
||||
mcp_server_support: true,
|
||||
mapping_mcp_servers: Default::default(),
|
||||
enabled_mcp_servers: None,
|
||||
mcp_tools: None,
|
||||
|
||||
auto_continue: false,
|
||||
max_auto_continues: 10,
|
||||
@@ -339,6 +339,7 @@ impl Default for Config {
|
||||
summarization_prompt: None,
|
||||
summary_context_prompt: None,
|
||||
max_tool_result_chars: None,
|
||||
max_concurrent_jobs: None,
|
||||
|
||||
memory: None,
|
||||
memory_cap_with_tools: None,
|
||||
@@ -388,12 +389,12 @@ pub enum AssetCategory {
|
||||
Macros,
|
||||
Functions,
|
||||
Skills,
|
||||
#[value(name = "mcp_config")]
|
||||
#[value(name = "mcp-config", alias = "mcp_config")]
|
||||
McpConfig,
|
||||
}
|
||||
|
||||
impl AssetCategory {
|
||||
pub const NAMES: [&'static str; 5] = ["agents", "macros", "functions", "skills", "mcp_config"];
|
||||
pub const NAMES: [&'static str; 5] = ["agents", "macros", "functions", "skills", "mcp-config"];
|
||||
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
@@ -401,7 +402,7 @@ impl AssetCategory {
|
||||
"macros" => Some(Self::Macros),
|
||||
"functions" => Some(Self::Functions),
|
||||
"skills" => Some(Self::Skills),
|
||||
"mcp_config" => Some(Self::McpConfig),
|
||||
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -420,7 +421,7 @@ pub enum InstallFilter {
|
||||
Skills,
|
||||
Macros,
|
||||
Functions,
|
||||
#[value(name = "mcp_config")]
|
||||
#[value(name = "mcp-config", alias = "mcp_config")]
|
||||
McpConfig,
|
||||
}
|
||||
|
||||
@@ -431,7 +432,7 @@ impl InstallFilter {
|
||||
"skills",
|
||||
"macros",
|
||||
"functions",
|
||||
"mcp_config",
|
||||
"mcp-config",
|
||||
];
|
||||
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
@@ -441,7 +442,7 @@ impl InstallFilter {
|
||||
"skills" => Some(Self::Skills),
|
||||
"macros" => Some(Self::Macros),
|
||||
"functions" => Some(Self::Functions),
|
||||
"mcp_config" => Some(Self::McpConfig),
|
||||
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -792,51 +793,20 @@ pub async fn create_config_file(config_path: &Path) -> Result<()> {
|
||||
|
||||
let client = Select::new("API Provider (required):", list_client_types()).prompt()?;
|
||||
|
||||
let mut config = json!({});
|
||||
let (model, clients_config) = create_client_config(client, &vault).await?;
|
||||
config["model"] = model.into();
|
||||
match &provider_choice {
|
||||
None => {
|
||||
config["vault_password_file"] =
|
||||
vault.local_password_file()?.display().to_string().into();
|
||||
}
|
||||
let secrets = match &provider_choice {
|
||||
None => json!({
|
||||
"vault_password_file": vault.local_password_file()?.display().to_string()
|
||||
}),
|
||||
Some(provider) => {
|
||||
config["secrets_provider"] = serde_json::to_value(provider)
|
||||
let provider = serde_json::to_value(provider)
|
||||
.with_context(|| "failed to serialize secrets_provider config")?;
|
||||
json!({ "secrets_provider": provider })
|
||||
}
|
||||
}
|
||||
config["stream"] = json!(true);
|
||||
config["save"] = json!(true);
|
||||
config["keybindings"] = json!("vi");
|
||||
config["wrap"] = json!("auto");
|
||||
config["wrap_code"] = json!(false);
|
||||
config["function_calling_support"] = json!(true);
|
||||
config["enabled_tools"] = json!(null);
|
||||
config["visible_tools"] = json!(DEFAULT_VISIBLE_TOOLS);
|
||||
config["mcp_server_support"] = json!(true);
|
||||
config["enabled_mcp_servers"] = json!(null);
|
||||
config["highlight"] = json!(true);
|
||||
config["light_theme"] = json!(false);
|
||||
config[CLIENTS_FIELD] = clients_config;
|
||||
};
|
||||
|
||||
let config_data = serde_yaml::to_string(&config).with_context(|| "Failed to create config")?;
|
||||
let config_data = format!(
|
||||
"# see https://github.com/Dark-Alex-17/coyote/blob/main/config.example.yaml\n\n{config_data}"
|
||||
);
|
||||
|
||||
ensure_parent_exists(config_path)?;
|
||||
std::fs::write(config_path, config_data)
|
||||
.with_context(|| format!("Failed to write to '{}'", config_path.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::prelude::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(config_path, perms)?;
|
||||
}
|
||||
|
||||
println!("✓ Saved the config file to '{}'.\n", config_path.display());
|
||||
|
||||
Ok(())
|
||||
let config_data = render_config_template(&model, Some(&secrets), &clients_config)?;
|
||||
write_config_file(config_path, &config_data)
|
||||
}
|
||||
|
||||
async fn create_config_file_sandbox(config_path: &Path) -> Result<()> {
|
||||
@@ -846,7 +816,7 @@ async fn create_config_file_sandbox(config_path: &Path) -> Result<()> {
|
||||
"Running in sandbox mode — your API provider credentials are managed by your host Coyote configuration if configured."
|
||||
);
|
||||
|
||||
let oai_api_base = client::OPENAI_COMPATIBLE_PROVIDERS
|
||||
let oai_api_base = OPENAI_COMPATIBLE_PROVIDERS
|
||||
.iter()
|
||||
.find(|(name, _)| *name == client)
|
||||
.map(|(_, url)| *url);
|
||||
@@ -859,13 +829,13 @@ async fn create_config_file_sandbox(config_path: &Path) -> Result<()> {
|
||||
} else {
|
||||
api_base.to_string()
|
||||
};
|
||||
serde_json::json!({
|
||||
json!({
|
||||
"type": "openai-compatible",
|
||||
"name": client,
|
||||
"api_base": api_base_str,
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "type": client })
|
||||
json!({ "type": client })
|
||||
};
|
||||
|
||||
if client::client_type_supports_oauth(client) {
|
||||
@@ -879,27 +849,45 @@ async fn create_config_file_sandbox(config_path: &Path) -> Result<()> {
|
||||
|
||||
let model = set_client_models_config(&mut client_config, client).await?;
|
||||
|
||||
let mut config = serde_json::json!({});
|
||||
config["model"] = model.into();
|
||||
config["stream"] = serde_json::json!(true);
|
||||
config["save"] = serde_json::json!(true);
|
||||
config["keybindings"] = serde_json::json!("vi");
|
||||
config["wrap"] = serde_json::json!("auto");
|
||||
config["wrap_code"] = serde_json::json!(false);
|
||||
config["function_calling_support"] = serde_json::json!(true);
|
||||
config["enabled_tools"] = serde_json::json!(null);
|
||||
config["visible_tools"] = serde_json::json!(DEFAULT_VISIBLE_TOOLS);
|
||||
config["mcp_server_support"] = serde_json::json!(true);
|
||||
config["enabled_mcp_servers"] = serde_json::json!(null);
|
||||
config["highlight"] = serde_json::json!(true);
|
||||
config["light_theme"] = serde_json::json!(false);
|
||||
config[CLIENTS_FIELD] = serde_json::json!(vec![client_config]);
|
||||
let config_data = render_config_template(&model, None, &json!([client_config]))?;
|
||||
write_config_file(config_path, &config_data)
|
||||
}
|
||||
|
||||
let config_data = serde_yaml::to_string(&config).with_context(|| "Failed to create config")?;
|
||||
let config_data = format!(
|
||||
"# see https://github.com/Dark-Alex-17/coyote/blob/main/config.example.yaml\n\n{config_data}"
|
||||
);
|
||||
fn render_config_template(
|
||||
model: &str,
|
||||
secrets: Option<&serde_json::Value>,
|
||||
clients: &serde_json::Value,
|
||||
) -> Result<String> {
|
||||
render_config_template_from(CONFIG_TEMPLATE, model, secrets, clients)
|
||||
}
|
||||
|
||||
fn render_config_template_from(
|
||||
template: &str,
|
||||
model: &str,
|
||||
secrets: Option<&serde_json::Value>,
|
||||
clients: &serde_json::Value,
|
||||
) -> Result<String> {
|
||||
let to_yaml = |value: &serde_json::Value| {
|
||||
serde_yaml::to_string(value).with_context(|| "Failed to create config")
|
||||
};
|
||||
|
||||
let model_block = to_yaml(&json!({ "model": model }))?;
|
||||
let secrets_block = match secrets {
|
||||
Some(value) => to_yaml(value)?,
|
||||
None => "# Sandbox mode: no vault provider is configured; secrets are provisioned\n\
|
||||
# from the host when the sandbox is created.\n"
|
||||
.to_string(),
|
||||
};
|
||||
let clients_block = to_yaml(&json!({ CLIENTS_FIELD: clients }))?;
|
||||
|
||||
Ok(template
|
||||
.replace("\r\n", "\n")
|
||||
.replacen("__MODEL_BLOCK__\n", &model_block, 1)
|
||||
.replacen("__SECRETS_BLOCK__\n", &secrets_block, 1)
|
||||
.replacen("__CLIENTS_BLOCK__\n", &clients_block, 1))
|
||||
}
|
||||
|
||||
fn write_config_file(config_path: &Path, config_data: &str) -> Result<()> {
|
||||
ensure_parent_exists(config_path)?;
|
||||
std::fs::write(config_path, config_data)
|
||||
.with_context(|| format!("Failed to write to '{}'", config_path.display()))?;
|
||||
@@ -1115,6 +1103,17 @@ clients:
|
||||
assert!(validate_no_template_in_secrets_provider(yaml).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_yaml_parses_mcp_tools() {
|
||||
let cfg: Config = serde_yaml::from_str("mcp_tools:\n github:\n - get_*\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
cfg.mcp_tools.as_ref().unwrap().get("github"),
|
||||
Some(&vec!["get_*".to_string()])
|
||||
);
|
||||
assert_eq!(Config::default().mcp_tools, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_defaults_match_expected() {
|
||||
let cfg = Config::default();
|
||||
@@ -1144,6 +1143,75 @@ clients:
|
||||
assert_eq!(cfg.enabled_macros, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_template_renders_parseable_config() {
|
||||
let secrets = json!({ "vault_password_file": "/home/user/.coyote_password" });
|
||||
let clients = json!([{ "type": "openai", "api_key": "sk-test" }]);
|
||||
|
||||
let rendered = render_config_template("openai:gpt-4o", Some(&secrets), &clients).unwrap();
|
||||
|
||||
assert!(!rendered.contains("__MODEL_BLOCK__"));
|
||||
assert!(!rendered.contains("__SECRETS_BLOCK__"));
|
||||
assert!(!rendered.contains("__CLIENTS_BLOCK__"));
|
||||
|
||||
let cfg = Config::load_from_str(&rendered).unwrap();
|
||||
assert_eq!(cfg.model_id, "openai:gpt-4o");
|
||||
assert_eq!(
|
||||
cfg.vault_password_file,
|
||||
Some(PathBuf::from("/home/user/.coyote_password"))
|
||||
);
|
||||
assert!(cfg.secrets_provider.is_none());
|
||||
assert_eq!(cfg.keybindings, "emacs");
|
||||
assert!(cfg.save);
|
||||
assert_eq!(cfg.wrap.as_deref(), Some("auto"));
|
||||
assert!(cfg.visible_tools.is_none());
|
||||
assert!(cfg.mapping_tools.is_empty());
|
||||
assert!(cfg.document_loaders.is_empty());
|
||||
assert_eq!(cfg.compression_threshold, 4000);
|
||||
assert!(cfg.theme.is_none());
|
||||
assert_eq!(cfg.clients.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_template_renders_parseable_sandbox_config() {
|
||||
let clients = json!([{ "type": "claude" }]);
|
||||
|
||||
let rendered =
|
||||
render_config_template("claude:claude-sonnet-4-20250514", None, &clients).unwrap();
|
||||
|
||||
assert!(!rendered.contains("__SECRETS_BLOCK__"));
|
||||
|
||||
let cfg = Config::load_from_str(&rendered).unwrap();
|
||||
assert_eq!(cfg.model_id, "claude:claude-sonnet-4-20250514");
|
||||
assert!(cfg.vault_password_file.is_none());
|
||||
assert!(cfg.secrets_provider.is_none());
|
||||
assert_eq!(cfg.keybindings, "emacs");
|
||||
assert!(cfg.save);
|
||||
assert!(cfg.visible_tools.is_none());
|
||||
assert_eq!(cfg.compression_threshold, 4000);
|
||||
assert_eq!(cfg.clients.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_template_renders_with_crlf_line_endings() {
|
||||
let crlf_template = CONFIG_TEMPLATE.replace('\n', "\r\n");
|
||||
let secrets = json!({ "vault_password_file": "/home/user/.coyote_password" });
|
||||
let clients = json!([{ "type": "openai", "api_key": "sk-test" }]);
|
||||
|
||||
let rendered =
|
||||
render_config_template_from(&crlf_template, "openai:gpt-4o", Some(&secrets), &clients)
|
||||
.unwrap();
|
||||
|
||||
assert!(!rendered.contains("__MODEL_BLOCK__"));
|
||||
assert!(!rendered.contains("__SECRETS_BLOCK__"));
|
||||
assert!(!rendered.contains("__CLIENTS_BLOCK__"));
|
||||
assert!(!rendered.contains('\r'));
|
||||
|
||||
let cfg = Config::load_from_str(&rendered).unwrap();
|
||||
assert_eq!(cfg.model_id, "openai:gpt-4o");
|
||||
assert_eq!(cfg.clients.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_enabled_macros_empty_string_is_some_empty() {
|
||||
let cfg: Config = serde_yaml::from_str("enabled_macros: \"\"").unwrap();
|
||||
|
||||
@@ -287,6 +287,18 @@ pub fn functions_bin_dir() -> PathBuf {
|
||||
}
|
||||
|
||||
pub fn mcp_config_file() -> PathBuf {
|
||||
let preferred = local_dir(MCP_FILE_NAME);
|
||||
if preferred.exists() {
|
||||
return preferred;
|
||||
}
|
||||
let legacy = legacy_mcp_config_file();
|
||||
if legacy.exists() {
|
||||
return legacy;
|
||||
}
|
||||
preferred
|
||||
}
|
||||
|
||||
pub fn legacy_mcp_config_file() -> PathBuf {
|
||||
functions_dir().join(MCP_FILE_NAME)
|
||||
}
|
||||
|
||||
@@ -837,6 +849,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
mod user_mcp_resolution {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
fn with_config_dir<F: FnOnce(&Path)>(f: F) {
|
||||
let unique = time::SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let root = env::temp_dir().join(format!("coyote-user-mcp-test-{unique}"));
|
||||
fs::create_dir_all(root.join(FUNCTIONS_DIR_NAME)).unwrap();
|
||||
let config_env = get_env_name("config_dir");
|
||||
let functions_env = get_env_name("functions_dir");
|
||||
let prev_config = env::var_os(&config_env);
|
||||
let prev_functions = env::var_os(&functions_env);
|
||||
unsafe {
|
||||
env::set_var(&config_env, &root);
|
||||
env::set_var(&functions_env, root.join(FUNCTIONS_DIR_NAME));
|
||||
}
|
||||
f(&root);
|
||||
unsafe {
|
||||
match prev_config {
|
||||
Some(v) => env::set_var(&config_env, v),
|
||||
None => env::remove_var(&config_env),
|
||||
}
|
||||
match prev_functions {
|
||||
Some(v) => env::set_var(&functions_env, v),
|
||||
None => env::remove_var(&functions_env),
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn defaults_to_preferred_location_when_neither_exists() {
|
||||
with_config_dir(|root| {
|
||||
assert_eq!(mcp_config_file(), root.join(MCP_FILE_NAME));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn falls_back_to_legacy_location_when_only_it_exists() {
|
||||
with_config_dir(|root| {
|
||||
let legacy = root.join(FUNCTIONS_DIR_NAME).join(MCP_FILE_NAME);
|
||||
fs::write(&legacy, "{}").unwrap();
|
||||
assert_eq!(mcp_config_file(), legacy);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn prefers_new_location_when_both_exist() {
|
||||
with_config_dir(|root| {
|
||||
let preferred = root.join(MCP_FILE_NAME);
|
||||
let legacy = root.join(FUNCTIONS_DIR_NAME).join(MCP_FILE_NAME);
|
||||
fs::write(&preferred, "{}").unwrap();
|
||||
fs::write(&legacy, "{}").unwrap();
|
||||
assert_eq!(mcp_config_file(), preferred);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn uses_preferred_location_when_only_it_exists() {
|
||||
with_config_dir(|root| {
|
||||
let preferred = root.join(MCP_FILE_NAME);
|
||||
fs::write(&preferred, "{}").unwrap();
|
||||
assert_eq!(mcp_config_file(), preferred);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_kit_override_reflects_env_var_state() {
|
||||
let env_name = get_env_name("sandbox_kit");
|
||||
|
||||
+22
-4
@@ -82,7 +82,7 @@ pub(in crate::config) const DEFAULT_SPAWN_INSTRUCTIONS: &str = indoc! {"
|
||||
| Tool | Purpose |
|
||||
|------|----------|
|
||||
| `agent__spawn` | Spawn a subagent in the background. Returns an `id` immediately. |
|
||||
| `agent__check` | Non-blocking check: is the agent done yet? Returns PENDING or result. |
|
||||
| `agent__check` | Non-blocking status probe: running or finished. Never returns/consumes the result — use `agent__collect`. |
|
||||
| `agent__collect` | Blocking wait: wait for an agent to finish, return its output. |
|
||||
| `agent__list_available` | List all agent types you can spawn (name + description). Use this to discover specialists before calling `agent__spawn`. |
|
||||
| `agent__list_running` | List all subagents YOU have spawned, with their status. |
|
||||
@@ -112,9 +112,10 @@ pub(in crate::config) const DEFAULT_SPAWN_INSTRUCTIONS: &str = indoc! {"
|
||||
|
||||
### CRITICAL: Never end your turn with pending agents
|
||||
|
||||
Spawned agents do NOT report back on their own. They run in the background until you
|
||||
actively reclaim them with `agent__collect` (to get their output) or `agent__cancel`
|
||||
(to discard them). If you spawn agents and then emit a final message without reclaiming
|
||||
Spawned agents do NOT deliver their results on their own. When one finishes, a
|
||||
`system_notifications` entry appears on your next tool result naming the exact collect
|
||||
command — but the output is only retrieved when you actively reclaim it with `agent__collect`
|
||||
(or discard it with `agent__cancel`). If you spawn agents and then emit a final message without reclaiming
|
||||
them, the system will detect the unreclaimed agents and reject the turn-end, injecting
|
||||
a reminder forcing you to handle them. After several such reminders, the system will
|
||||
auto-cancel them and warn you that work was lost.
|
||||
@@ -190,6 +191,23 @@ pub(in crate::config) const DEFAULT_SPAWN_INSTRUCTIONS: &str = indoc! {"
|
||||
4. **Respond promptly**; the child agent is blocked and waiting (5-minute timeout).
|
||||
"};
|
||||
|
||||
pub(in crate::config) const DEFAULT_JOB_INSTRUCTIONS: &str = indoc! {"
|
||||
## Background Jobs
|
||||
|
||||
For long-running tool calls (builds, test suites, slow commands), call `job__start` and keep
|
||||
working instead of blocking — completion arrives as a `system_notifications` entry on your
|
||||
next tool result. Check progress with `job__check` (sparingly), block on the result with
|
||||
`job__collect` (only when you have nothing else to do), cancel with `job__cancel`, and list
|
||||
jobs with `job__list`. Collected results over 50,000 chars are tail-capped; collecting is
|
||||
consume-once, so when you need the complete output pass `full_result: true` (or have the
|
||||
command write to a file). Collect or cancel every job you started before ending your turn. In
|
||||
graph LLM nodes, jobs are node-local: collect or cancel every job you start before the node
|
||||
ends — an uncollected job burns node iterations via the guardrail, and anything still
|
||||
running when the node exits is cancelled with its result discarded. Jobs run against a
|
||||
snapshot of the current config/environment and do not survive coyote exiting.
|
||||
"
|
||||
};
|
||||
|
||||
pub(in crate::config) const DEFAULT_TEAMMATE_INSTRUCTIONS: &str = indoc! {"
|
||||
## Teammate Messaging
|
||||
|
||||
|
||||
+2172
-157
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ pub trait RoleLike {
|
||||
fn top_p(&self) -> Option<f64>;
|
||||
fn enabled_tools(&self) -> Option<Vec<String>>;
|
||||
fn enabled_mcp_servers(&self) -> Option<Vec<String>>;
|
||||
fn mcp_tools(&self) -> Option<IndexMap<String, Vec<String>>>;
|
||||
fn set_model(&mut self, model: Model);
|
||||
fn set_temperature(&mut self, value: Option<f64>);
|
||||
fn reasoning_effort(&self) -> Option<String>;
|
||||
@@ -37,6 +38,7 @@ pub trait RoleLike {
|
||||
fn set_reasoning_effort(&mut self, value: Option<String>);
|
||||
fn set_enabled_tools(&mut self, value: Option<Vec<String>>);
|
||||
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>);
|
||||
fn set_mcp_tools(&mut self, value: Option<IndexMap<String, Vec<String>>>);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -67,6 +69,8 @@ pub struct Role {
|
||||
deserialize_with = "super::deserialize_csv_or_vec"
|
||||
)]
|
||||
enabled_mcp_servers: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
skills_enabled: Option<bool>,
|
||||
#[serde(
|
||||
@@ -133,6 +137,7 @@ impl Role {
|
||||
"enabled_mcp_servers" => {
|
||||
role.enabled_mcp_servers = parse_string_or_array(value)
|
||||
}
|
||||
"mcp_tools" => role.mcp_tools = parse_mcp_tools_map(value),
|
||||
"skills_enabled" => role.skills_enabled = value.as_bool(),
|
||||
"enabled_skills" => role.enabled_skills = parse_string_or_array(value),
|
||||
"enabled_macros" => role.enabled_macros = parse_string_or_array(value),
|
||||
@@ -196,6 +201,10 @@ impl Role {
|
||||
serde_json::to_string(enabled_mcp_servers).unwrap_or_else(|_| "[]".to_string());
|
||||
metadata.push(format!("enabled_mcp_servers: {inline}"));
|
||||
}
|
||||
if let Some(mcp_tools) = &self.mcp_tools {
|
||||
let inline = serde_json::to_string(mcp_tools).unwrap_or_else(|_| "{}".to_string());
|
||||
metadata.push(format!("mcp_tools: {inline}"));
|
||||
}
|
||||
if let Some(skills_enabled) = self.skills_enabled {
|
||||
metadata.push(format!("skills_enabled: {skills_enabled}"));
|
||||
}
|
||||
@@ -279,6 +288,10 @@ impl Role {
|
||||
enabled_tools,
|
||||
enabled_mcp_servers,
|
||||
);
|
||||
let mcp_tools = role_like.mcp_tools();
|
||||
if mcp_tools.is_some() {
|
||||
self.set_mcp_tools(mcp_tools);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn batch_set(
|
||||
@@ -453,6 +466,10 @@ impl RoleLike for Role {
|
||||
self.enabled_mcp_servers.clone()
|
||||
}
|
||||
|
||||
fn mcp_tools(&self) -> Option<IndexMap<String, Vec<String>>> {
|
||||
self.mcp_tools.clone()
|
||||
}
|
||||
|
||||
fn set_model(&mut self, model: Model) {
|
||||
if !self.model().id().is_empty() {
|
||||
self.model_id = Some(model.id().to_string());
|
||||
@@ -479,6 +496,10 @@ impl RoleLike for Role {
|
||||
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>) {
|
||||
self.enabled_mcp_servers = value;
|
||||
}
|
||||
|
||||
fn set_mcp_tools(&mut self, value: Option<IndexMap<String, Vec<String>>>) {
|
||||
self.mcp_tools = value;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_string_or_array(value: &Value) -> Option<Vec<String>> {
|
||||
@@ -503,6 +524,19 @@ fn parse_string_or_array(value: &Value) -> Option<Vec<String>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_mcp_tools_map(value: &Value) -> Option<IndexMap<String, Vec<String>>> {
|
||||
let map = value.as_object()?;
|
||||
let mut mcp_tools = IndexMap::new();
|
||||
for (server, tools) in map {
|
||||
if tools.is_null() {
|
||||
mcp_tools.insert(server.clone(), Vec::new());
|
||||
} else if let Some(tools) = parse_string_or_array(tools) {
|
||||
mcp_tools.insert(server.clone(), tools);
|
||||
}
|
||||
}
|
||||
Some(mcp_tools)
|
||||
}
|
||||
|
||||
fn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) {
|
||||
let mut text = prompt;
|
||||
let mut search_input = true;
|
||||
@@ -652,6 +686,69 @@ mod tests {
|
||||
assert_eq!(role.enabled_macros, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_new_parses_mcp_tools_list_and_csv_values() {
|
||||
let content = "---\nmcp_tools:\n github: [get_*, list_*, search_code]\n slack: conversations_history,conversations_replies\n---\nPrompt";
|
||||
|
||||
let role = Role::new("test", content);
|
||||
|
||||
let mcp_tools = role.mcp_tools().unwrap();
|
||||
assert_eq!(
|
||||
mcp_tools.get("github"),
|
||||
Some(&vec![
|
||||
"get_*".to_string(),
|
||||
"list_*".to_string(),
|
||||
"search_code".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
mcp_tools.get("slack"),
|
||||
Some(&vec![
|
||||
"conversations_history".to_string(),
|
||||
"conversations_replies".to_string()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_new_mcp_tools_empty_list_server_is_some_empty() {
|
||||
let role = Role::new("test", "---\nmcp_tools:\n github: []\n---\nPrompt");
|
||||
|
||||
assert_eq!(role.mcp_tools().unwrap().get("github"), Some(&vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_new_mcp_tools_per_server_null_is_some_empty() {
|
||||
let role = Role::new("test", "---\nmcp_tools:\n github:\n---\nPrompt");
|
||||
|
||||
assert_eq!(role.mcp_tools().unwrap().get("github"), Some(&vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_new_mcp_tools_absent_is_none() {
|
||||
let role = Role::new("test", "---\ntemperature: 0.5\n---\nPrompt");
|
||||
|
||||
assert_eq!(role.mcp_tools(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_new_mcp_tools_null_is_none() {
|
||||
let role = Role::new("test", "---\nmcp_tools: null\n---\nPrompt");
|
||||
|
||||
assert_eq!(role.mcp_tools(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_export_mcp_tools_round_trips() {
|
||||
let content = "---\nmcp_tools:\n github: [get_issue]\n slack: a,b\n---\nPrompt";
|
||||
let role = Role::new("test", content);
|
||||
|
||||
let reparsed = Role::new("test", &role.export());
|
||||
|
||||
assert_eq!(reparsed.mcp_tools(), role.mcp_tools());
|
||||
assert!(role.mcp_tools().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_export_includes_enabled_macros() {
|
||||
let role = Role::new("test", "---\nenabled_macros: [a]\n---\nPrompt");
|
||||
|
||||
+152
-2
@@ -16,6 +16,8 @@ use std::sync::LazyLock;
|
||||
|
||||
static RE_AUTONAME_PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d{8}T\d{6}-").unwrap());
|
||||
|
||||
pub const INTERRUPTED_RESPONSE_TEXT: &str = "[Response interrupted due to error]";
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct Session {
|
||||
#[serde(rename(serialize = "model", deserialize = "model"))]
|
||||
@@ -38,6 +40,8 @@ pub struct Session {
|
||||
deserialize_with = "super::deserialize_csv_or_vec"
|
||||
)]
|
||||
enabled_mcp_servers: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
skills_enabled: Option<bool>,
|
||||
#[serde(
|
||||
@@ -175,6 +179,13 @@ impl Session {
|
||||
self.messages.is_empty() && self.compressed_messages.is_empty()
|
||||
}
|
||||
|
||||
pub fn has_interrupted_error_checkpoint(&self) -> bool {
|
||||
self.messages.last().is_some_and(|message| {
|
||||
message.role.is_assistant()
|
||||
&& matches!(&message.content, MessageContent::Text(text) if text.ends_with(INTERRUPTED_RESPONSE_TEXT))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn messages(&self) -> &[Message] {
|
||||
&self.messages
|
||||
}
|
||||
@@ -240,6 +251,9 @@ impl Session {
|
||||
if let Some(enabled_mcp_servers) = self.enabled_mcp_servers() {
|
||||
data["enabled_mcp_servers"] = json!(enabled_mcp_servers);
|
||||
}
|
||||
if let Some(mcp_tools) = self.mcp_tools() {
|
||||
data["mcp_tools"] = json!(mcp_tools);
|
||||
}
|
||||
if let Some(skills_enabled) = self.skills_enabled() {
|
||||
data["skills_enabled"] = skills_enabled.into();
|
||||
}
|
||||
@@ -320,6 +334,13 @@ impl Session {
|
||||
items.push(("enabled_mcp_servers", enabled_mcp_servers.join(",")));
|
||||
}
|
||||
|
||||
if let Some(mcp_tools) = self.mcp_tools() {
|
||||
items.push((
|
||||
"mcp_tools",
|
||||
serde_json::to_string(&mcp_tools).unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(skills_enabled) = self.skills_enabled() {
|
||||
items.push(("skills_enabled", skills_enabled.to_string()));
|
||||
}
|
||||
@@ -861,6 +882,10 @@ impl RoleLike for Session {
|
||||
self.enabled_mcp_servers.clone()
|
||||
}
|
||||
|
||||
fn mcp_tools(&self) -> Option<IndexMap<String, Vec<String>>> {
|
||||
self.mcp_tools.clone()
|
||||
}
|
||||
|
||||
fn set_model(&mut self, model: Model) {
|
||||
if self.model().id() != model.id() {
|
||||
self.model_id = model.id();
|
||||
@@ -904,6 +929,13 @@ impl RoleLike for Session {
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mcp_tools(&mut self, value: Option<IndexMap<String, Vec<String>>>) {
|
||||
if self.mcp_tools != value {
|
||||
self.mcp_tools = value;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -934,9 +966,9 @@ impl AutoName {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::client::{Message, MessageContent, MessageRole, Model};
|
||||
use crate::client::{Message, MessageContent, MessageContentToolCalls, MessageRole, Model};
|
||||
use crate::config::{AppConfig, AppState, RequestContext, WorkingMode};
|
||||
use crate::function::Functions;
|
||||
use crate::function::{Functions, ToolCall, ToolResult};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
@@ -949,6 +981,85 @@ mod tests {
|
||||
assert!(!session.dirty());
|
||||
}
|
||||
|
||||
fn push_interrupted_turn(session: &mut Session) {
|
||||
session.messages.push(Message::new(
|
||||
MessageRole::User,
|
||||
MessageContent::Text("do things".to_string()),
|
||||
));
|
||||
session.messages.push(Message::new(
|
||||
MessageRole::Tool,
|
||||
MessageContent::ToolCalls(MessageContentToolCalls::new(
|
||||
vec![ToolResult::new(ToolCall::default(), json!("ok"))],
|
||||
String::new(),
|
||||
)),
|
||||
));
|
||||
session.messages.push(Message::new(
|
||||
MessageRole::Assistant,
|
||||
MessageContent::Text(INTERRUPTED_RESPONSE_TEXT.to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_has_interrupted_error_checkpoint_detects_sentinel() {
|
||||
let mut session = Session::default();
|
||||
assert!(!session.has_interrupted_error_checkpoint());
|
||||
|
||||
session.messages.push(Message::new(
|
||||
MessageRole::User,
|
||||
MessageContent::Text("hi".to_string()),
|
||||
));
|
||||
session.messages.push(Message::new(
|
||||
MessageRole::Assistant,
|
||||
MessageContent::Text("hello".to_string()),
|
||||
));
|
||||
assert!(
|
||||
!session.has_interrupted_error_checkpoint(),
|
||||
"a normal completed exchange is not an interruption"
|
||||
);
|
||||
|
||||
push_interrupted_turn(&mut session);
|
||||
assert!(session.has_interrupted_error_checkpoint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_detects_checkpoint_appended_by_crashed_continue_turn() {
|
||||
// A crash during a `.continue` turn appends the sentinel to the
|
||||
// previous assistant text (add_message's continue_output branch)
|
||||
// instead of pushing a standalone checkpoint message.
|
||||
let mut session = Session::default();
|
||||
session.messages.push(Message::new(
|
||||
MessageRole::User,
|
||||
MessageContent::Text("hi".to_string()),
|
||||
));
|
||||
session.messages.push(Message::new(
|
||||
MessageRole::Assistant,
|
||||
MessageContent::Text(format!("partial answer{INTERRUPTED_RESPONSE_TEXT}")),
|
||||
));
|
||||
|
||||
assert!(session.has_interrupted_error_checkpoint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_interrupted_checkpoint_with_tool_calls_survives_yaml_round_trip() {
|
||||
let mut session = Session::default();
|
||||
push_interrupted_turn(&mut session);
|
||||
|
||||
let yaml = serde_yaml::to_string(&session).unwrap();
|
||||
let reloaded: Session = serde_yaml::from_str(&yaml).unwrap();
|
||||
|
||||
assert!(
|
||||
reloaded.has_interrupted_error_checkpoint(),
|
||||
"interruption checkpoint must survive save/reload"
|
||||
);
|
||||
assert!(
|
||||
reloaded.messages.iter().any(|m| matches!(
|
||||
&m.content,
|
||||
MessageContent::ToolCalls(tc) if tc.tool_results.len() == 1
|
||||
)),
|
||||
"tool calls made before the crash must survive save/reload"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_enabled_macros_absent_is_none() {
|
||||
let session: Session = serde_yaml::from_str("model: provider:test\nmessages: []").unwrap();
|
||||
@@ -956,6 +1067,45 @@ mod tests {
|
||||
assert_eq!(session.enabled_macros, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_mcp_tools_survives_yaml_round_trip() {
|
||||
let mut session = Session::default();
|
||||
let mut mcp_tools = IndexMap::new();
|
||||
mcp_tools.insert("github".to_string(), vec!["get_*".to_string()]);
|
||||
mcp_tools.insert("slack".to_string(), vec![]);
|
||||
session.set_mcp_tools(Some(mcp_tools.clone()));
|
||||
|
||||
let yaml = serde_yaml::to_string(&session).unwrap();
|
||||
let reloaded: Session = serde_yaml::from_str(&yaml).unwrap();
|
||||
|
||||
assert_eq!(reloaded.mcp_tools(), Some(mcp_tools));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_set_role_does_not_copy_mcp_tools() {
|
||||
let role = Role::new(
|
||||
"test",
|
||||
"---\nmcp_tools:\n github: [get_issue]\n---\nPrompt",
|
||||
);
|
||||
assert!(role.mcp_tools().is_some());
|
||||
let mut session = Session::default();
|
||||
|
||||
session.set_role(role);
|
||||
|
||||
assert_eq!(session.mcp_tools(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_set_mcp_tools_marks_dirty() {
|
||||
let mut session = Session::default();
|
||||
assert!(!session.dirty());
|
||||
|
||||
session.set_mcp_tools(Some(IndexMap::new()));
|
||||
|
||||
assert!(session.dirty());
|
||||
assert_eq!(session.mcp_tools(), Some(IndexMap::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_enabled_macros_empty_list_is_some_empty() {
|
||||
let session: Session =
|
||||
|
||||
@@ -37,6 +37,8 @@ pub struct Skill {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled_mcp_servers: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
auto_unload: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -74,6 +76,9 @@ impl Skill {
|
||||
"enabled_mcp_servers" => {
|
||||
skill.enabled_mcp_servers = parse_skill_string_or_array(value);
|
||||
}
|
||||
"mcp_tools" => {
|
||||
skill.mcp_tools = parse_skill_mcp_tools_map(value);
|
||||
}
|
||||
"auto_unload" => {
|
||||
skill.auto_unload = value.as_bool();
|
||||
}
|
||||
@@ -147,6 +152,10 @@ impl Skill {
|
||||
self.enabled_mcp_servers.as_deref()
|
||||
}
|
||||
|
||||
pub fn mcp_tools(&self) -> Option<&IndexMap<String, Vec<String>>> {
|
||||
self.mcp_tools.as_ref()
|
||||
}
|
||||
|
||||
pub fn auto_unload(&self) -> bool {
|
||||
self.auto_unload.unwrap_or(false)
|
||||
}
|
||||
@@ -185,6 +194,21 @@ fn parse_skill_string_or_array(value: &Value) -> Option<Vec<String>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_skill_mcp_tools_map(value: &Value) -> Option<IndexMap<String, Vec<String>>> {
|
||||
let map = value.as_object()?;
|
||||
let mut mcp_tools = IndexMap::new();
|
||||
|
||||
for (server, tools) in map {
|
||||
if tools.is_null() {
|
||||
mcp_tools.insert(server.clone(), Vec::new());
|
||||
} else if let Some(tools) = parse_skill_string_or_array(tools) {
|
||||
mcp_tools.insert(server.clone(), tools);
|
||||
}
|
||||
}
|
||||
|
||||
Some(mcp_tools)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -198,6 +222,44 @@ mod tests {
|
||||
assert_eq!(skill.description(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_new_parses_mcp_tools_list_and_csv_values() {
|
||||
let content = "---\nmcp_tools:\n github: [get_*, list_*]\n slack: a,b\n---\nBody";
|
||||
|
||||
let skill = Skill::new("test", content);
|
||||
|
||||
let mcp_tools = skill.mcp_tools().unwrap();
|
||||
assert_eq!(
|
||||
mcp_tools.get("github"),
|
||||
Some(&vec!["get_*".to_string(), "list_*".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
mcp_tools.get("slack"),
|
||||
Some(&vec!["a".to_string(), "b".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_new_mcp_tools_absent_is_none() {
|
||||
let skill = Skill::new("test", "---\ndescription: d\n---\nBody");
|
||||
|
||||
assert_eq!(skill.mcp_tools(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_new_mcp_tools_empty_list_server_is_some_empty() {
|
||||
let skill = Skill::new("test", "---\nmcp_tools:\n github: []\n---\nBody");
|
||||
|
||||
assert_eq!(skill.mcp_tools().unwrap().get("github"), Some(&vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_new_mcp_tools_per_server_null_is_some_empty() {
|
||||
let skill = Skill::new("test", "---\nmcp_tools:\n github:\n---\nBody");
|
||||
|
||||
assert_eq!(skill.mcp_tools().unwrap().get("github"), Some(&vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_new_parses_full_metadata() {
|
||||
let content = "---\n\
|
||||
|
||||
@@ -34,6 +34,10 @@ impl SkillRegistry {
|
||||
self.loaded.keys().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn loaded_skills(&self) -> impl Iterator<Item = &Skill> {
|
||||
self.loaded.values()
|
||||
}
|
||||
|
||||
pub fn loaded_mcp_servers(&self) -> BTreeSet<String> {
|
||||
let mut out = BTreeSet::new();
|
||||
for skill in self.loaded.values() {
|
||||
|
||||
+1689
-34
File diff suppressed because it is too large
Load Diff
@@ -70,7 +70,9 @@ fn normalize_version(requested: Option<String>) -> Option<String> {
|
||||
|
||||
fn preferred_update_target() -> Option<&'static str> {
|
||||
match (env::consts::OS, env::consts::ARCH) {
|
||||
("linux", "x86_64") if cfg!(target_env = "gnu") => Some("x86_64-unknown-linux-gnu"),
|
||||
("linux", "x86_64") => Some("x86_64-unknown-linux-musl"),
|
||||
("linux", "aarch64") if cfg!(target_env = "gnu") => Some("aarch64-unknown-linux-gnu"),
|
||||
("linux", "aarch64") => Some("aarch64-unknown-linux-musl"),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2718
-215
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -2,7 +2,7 @@ use super::state::StateManager;
|
||||
use super::structured;
|
||||
use super::types::AgentNode;
|
||||
use crate::config::RequestContext;
|
||||
use crate::function::supervisor::run_agent_for_graph;
|
||||
use crate::function::agents::run_agent_for_graph;
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
|
||||
+86
-1
@@ -424,7 +424,7 @@ async fn step(
|
||||
Ok(StepResult::Continue(vec![next]))
|
||||
}
|
||||
NodeType::Llm(llm_node) => {
|
||||
let outcome = LlmNodeExecutor::execute(llm_node, state, ctx).await?;
|
||||
let outcome = LlmNodeExecutor::execute(current, llm_node, state, ctx).await?;
|
||||
let targets = match outcome {
|
||||
LlmExecutionOutcome::Continue => static_next_targets(node, current, "llm")?,
|
||||
LlmExecutionOutcome::FellBack(target) => vec![target],
|
||||
@@ -563,8 +563,14 @@ mod tests {
|
||||
mod integration_tests {
|
||||
use super::*;
|
||||
use crate::config::{AppState, WorkingMode};
|
||||
#[cfg(unix)]
|
||||
use crate::function::jobs::RingBuf;
|
||||
#[cfg(unix)]
|
||||
use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus, Supervisor, notification};
|
||||
use crate::utils::{create_abort_signal, temp_file};
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::mem;
|
||||
|
||||
fn cmd_available(name: &str) -> bool {
|
||||
which::which(name).is_ok()
|
||||
@@ -856,4 +862,83 @@ nodes:
|
||||
);
|
||||
assert!(err.contains("sleeper"), "error should name frontier: {err}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn background_job_survives_graph_node_execution() {
|
||||
if !cmd_available("bash") {
|
||||
eprintln!("skipping: bash not available");
|
||||
return;
|
||||
}
|
||||
let ws = TestWorkspace::new();
|
||||
ws.write_script("noop.sh", "#!/bin/bash\necho '{}'\n");
|
||||
|
||||
let yaml = r#"
|
||||
name: background_job_survival_test
|
||||
start: noop
|
||||
nodes:
|
||||
noop:
|
||||
type: script
|
||||
script: noop.sh
|
||||
state_updates: {}
|
||||
next: done
|
||||
done:
|
||||
type: end
|
||||
output: "done"
|
||||
"#;
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let join_handle = rt.spawn(async {
|
||||
Ok(JobResult {
|
||||
output: Value::Null,
|
||||
exit_code: Some(0),
|
||||
output_bytes_captured: 0,
|
||||
})
|
||||
});
|
||||
mem::forget(rt);
|
||||
let handle = JobHandle {
|
||||
id: "job_bg".to_string(),
|
||||
tool: "execute_command".to_string(),
|
||||
started_at: Instant::now(),
|
||||
join_handle,
|
||||
abort_signal: create_abort_signal(),
|
||||
state: Arc::new(parking_lot::Mutex::new(JobState {
|
||||
status: JobStatus::Completed,
|
||||
pgid: None,
|
||||
})),
|
||||
output_buf: Arc::new(parking_lot::Mutex::new(RingBuf::default())),
|
||||
no_change_checks: 0,
|
||||
last_check_state: None,
|
||||
};
|
||||
let mut sup = Supervisor::new(0, 3).with_max_concurrent_jobs(4);
|
||||
sup.register(handle).unwrap();
|
||||
|
||||
let mut ctx = make_ctx();
|
||||
ctx.supervisor = Some(Arc::new(parking_lot::RwLock::new(sup)));
|
||||
ctx.notification_queue.push(notification::job_notification(
|
||||
"job_bg",
|
||||
"execute_command",
|
||||
true,
|
||||
));
|
||||
|
||||
let abort = create_abort_signal();
|
||||
let result = GraphExecutor::new(graph, &ws.dir)
|
||||
.execute(&mut ctx, abort)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("executor failed: {e:#}"));
|
||||
assert_eq!(result, "done");
|
||||
|
||||
assert!(
|
||||
ctx.supervisor.as_ref().unwrap().read().has_job("job_bg"),
|
||||
"graph execution must not touch registered job handles"
|
||||
);
|
||||
let events = ctx.notification_queue.drain();
|
||||
assert_eq!(events.len(), 1, "queued notification must survive the run");
|
||||
assert_eq!(events[0].id, "job_bg");
|
||||
assert_eq!(events[0].event, "job_completed");
|
||||
}
|
||||
}
|
||||
|
||||
+22
-3
@@ -6,8 +6,9 @@ use crate::config::prompts::DEFAULT_SKILL_INSTRUCTIONS;
|
||||
use crate::config::{
|
||||
Input, RequestContext, Role, RoleLike, SkillPolicy, should_inject_skill_instructions,
|
||||
};
|
||||
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
||||
use crate::function::jobs::reap_jobs;
|
||||
use crate::function::skill::skill_function_declarations;
|
||||
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
||||
use crate::utils::create_abort_signal;
|
||||
use anyhow::{Context, Error, Result, anyhow, bail};
|
||||
use log::warn;
|
||||
@@ -29,11 +30,12 @@ pub struct LlmNodeExecutor;
|
||||
|
||||
impl LlmNodeExecutor {
|
||||
pub(super) async fn execute(
|
||||
node_id: &str,
|
||||
node: &LlmNode,
|
||||
state_manager: &mut StateManager,
|
||||
parent_ctx: &mut RequestContext,
|
||||
) -> Result<LlmExecutionOutcome> {
|
||||
let result = run(node, state_manager, parent_ctx).await;
|
||||
let result = run(node_id, node, state_manager, parent_ctx).await;
|
||||
let (output, failure_reason) = match result {
|
||||
Ok(raw) => match &node.output_schema {
|
||||
Some(schema) => match structured::extract(&raw, schema, parent_ctx).await {
|
||||
@@ -78,6 +80,7 @@ fn outcome_from(
|
||||
}
|
||||
|
||||
async fn run(
|
||||
node_id: &str,
|
||||
node: &LlmNode,
|
||||
state_manager: &mut StateManager,
|
||||
parent_ctx: &mut RequestContext,
|
||||
@@ -173,6 +176,16 @@ async fn run(
|
||||
|
||||
let saved_role = parent_ctx.role.clone();
|
||||
parent_ctx.role = Some(composed_role);
|
||||
// Jobs are node-local: everything job__start registers while this node
|
||||
// runs is recorded here and reaped on every exit path below.
|
||||
let saved_job_scope = parent_ctx.node_job_scope.replace(Vec::new());
|
||||
// The node's tool filter layer lives in tracked context state so any
|
||||
// mid-node filter recompute (e.g. a skill load) re-applies it last.
|
||||
let saved_node_mcp_tools = std::mem::replace(
|
||||
&mut parent_ctx.active_node_mcp_tools,
|
||||
node.mcp_tools.clone().map(|map| (node_id.to_string(), map)),
|
||||
);
|
||||
parent_ctx.refresh_mcp_tool_filters();
|
||||
let result = match node.timeout {
|
||||
Some(secs) => match timeout(
|
||||
Duration::from_secs(secs),
|
||||
@@ -186,6 +199,11 @@ async fn run(
|
||||
None => run_with_retries(node, &prompt, parent_ctx).await,
|
||||
};
|
||||
parent_ctx.role = saved_role;
|
||||
let node_jobs =
|
||||
std::mem::replace(&mut parent_ctx.node_job_scope, saved_job_scope).unwrap_or_default();
|
||||
reap_jobs(parent_ctx.supervisor.as_ref(), &node_jobs).await;
|
||||
parent_ctx.active_node_mcp_tools = saved_node_mcp_tools;
|
||||
parent_ctx.refresh_mcp_tool_filters();
|
||||
restore_agent_skill_policy(parent_ctx, saved_agent_skill_state);
|
||||
result
|
||||
}
|
||||
@@ -268,7 +286,7 @@ async fn run_chat_loop(node: &LlmNode, prompt: &str, ctx: &mut RequestContext) -
|
||||
}
|
||||
|
||||
if tool_results.is_empty() {
|
||||
match check_pending_agents_guardrail(ctx) {
|
||||
match check_pending_tasks_guardrail(ctx) {
|
||||
GuardrailAction::NoAction => return Ok(accumulated),
|
||||
GuardrailAction::ForceTerminate(ids) => {
|
||||
warn!(
|
||||
@@ -499,6 +517,7 @@ mod tests {
|
||||
instructions: Some("sys".into()),
|
||||
prompt: "user".into(),
|
||||
tools: None,
|
||||
mcp_tools: None,
|
||||
model: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
|
||||
+5
-3
@@ -85,9 +85,11 @@ impl MapNodeExecutor {
|
||||
let mut ctx = sub_ctx;
|
||||
|
||||
let exec_result: Result<()> = match &branch_clone.node_type {
|
||||
NodeType::Llm(n) => LlmNodeExecutor::execute(n, &mut state, &mut ctx)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
NodeType::Llm(n) => {
|
||||
LlmNodeExecutor::execute(&branch_clone.id, n, &mut state, &mut ctx)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
NodeType::Agent(n) => AgentNodeExecutor::execute(n, &mut state, &mut ctx)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
|
||||
@@ -28,12 +28,18 @@ pub struct Graph {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_concurrent_jobs: Option<usize>,
|
||||
|
||||
#[serde(default)]
|
||||
pub global_tools: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Vec<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skills_enabled: Option<bool>,
|
||||
|
||||
@@ -282,6 +288,9 @@ pub struct LlmNode {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<String>>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_tools: Option<IndexMap<String, Vec<String>>>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
@@ -527,6 +536,47 @@ nodes:
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_mcp_tools_at_graph_and_node_level() {
|
||||
let yaml = r#"
|
||||
name: g
|
||||
mcp_servers: [github]
|
||||
mcp_tools:
|
||||
github:
|
||||
- get_*
|
||||
- list_*
|
||||
start: l
|
||||
nodes:
|
||||
l:
|
||||
id: l
|
||||
type: llm
|
||||
prompt: hi
|
||||
mcp_tools:
|
||||
github:
|
||||
- search_code
|
||||
next: e
|
||||
e:
|
||||
id: e
|
||||
type: end
|
||||
output: done
|
||||
"#;
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
graph.mcp_tools.as_ref().unwrap().get("github"),
|
||||
Some(&vec!["get_*".to_string(), "list_*".to_string()])
|
||||
);
|
||||
match &graph.get_node("l").unwrap().node_type {
|
||||
NodeType::Llm(llm) => {
|
||||
assert_eq!(
|
||||
llm.mcp_tools.as_ref().unwrap().get("github"),
|
||||
Some(&vec!["search_code".to_string()])
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Llm variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_every_node_type() {
|
||||
let yaml = r#"
|
||||
@@ -895,6 +945,7 @@ nodes:
|
||||
assert!(graph.model.is_none());
|
||||
assert!(graph.temperature.is_none());
|
||||
assert!(graph.top_p.is_none());
|
||||
assert!(graph.max_concurrent_jobs.is_none());
|
||||
assert!(graph.global_tools.is_empty());
|
||||
assert!(graph.mcp_servers.is_empty());
|
||||
assert!(graph.conversation_starters.is_empty());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::state::template_root_keys;
|
||||
use super::types::{Graph, Node, NodeType};
|
||||
use crate::client::{Model, ModelType};
|
||||
use crate::config;
|
||||
use crate::config::{Agent, AppConfig, paths};
|
||||
use crate::rag::{GraphRagConfig, RagData};
|
||||
use anyhow::{Result, bail};
|
||||
@@ -214,6 +215,14 @@ impl GraphValidator {
|
||||
return;
|
||||
};
|
||||
|
||||
let expand_alias =
|
||||
|name: &str| config::expand_mcp_server_alias(&ctx.app_config.mapping_mcp_servers, name);
|
||||
let mut enabled_servers: HashSet<String> = ctx.mcp_servers.clone();
|
||||
for server in &ctx.mcp_servers {
|
||||
enabled_servers.extend(expand_alias(server));
|
||||
}
|
||||
let all_servers_enabled = ctx.mcp_servers.iter().any(|s| s.trim() == "all");
|
||||
|
||||
for (node_id, node) in &graph.nodes {
|
||||
let NodeType::Llm(llm) = &node.node_type else {
|
||||
continue;
|
||||
@@ -237,6 +246,25 @@ impl GraphValidator {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mcp_tools) = &llm.mcp_tools
|
||||
&& !all_servers_enabled
|
||||
{
|
||||
for key in mcp_tools.keys() {
|
||||
let enabled = enabled_servers.contains(key)
|
||||
|| expand_alias(key)
|
||||
.iter()
|
||||
.any(|id| enabled_servers.contains(id));
|
||||
if !enabled {
|
||||
result.error(ValidationError::with_node(
|
||||
node_id,
|
||||
format!(
|
||||
"llm node 'mcp_tools' references MCP server '{key}' not enabled by this graph"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(model_id) = &llm.model
|
||||
&& Model::retrieve_model(ctx.app_config.as_ref(), model_id, ModelType::Chat)
|
||||
.is_err()
|
||||
@@ -998,8 +1026,10 @@ mod tests {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
reasoning_effort: None,
|
||||
max_concurrent_jobs: None,
|
||||
global_tools: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
mcp_tools: None,
|
||||
skills_enabled: None,
|
||||
enabled_skills: None,
|
||||
inject_skill_instructions: None,
|
||||
@@ -1098,6 +1128,7 @@ mod tests {
|
||||
instructions: None,
|
||||
prompt: "p".into(),
|
||||
tools: None,
|
||||
mcp_tools: None,
|
||||
model: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
@@ -1256,6 +1287,19 @@ mod tests {
|
||||
node
|
||||
}
|
||||
|
||||
fn llm_node_with_mcp_tools(id: &str, servers: &[&str]) -> Node {
|
||||
let mut node = llm_node(id, None, Some("end"));
|
||||
if let NodeType::Llm(ref mut n) = node.node_type {
|
||||
let mut mcp_tools = IndexMap::new();
|
||||
for server in servers {
|
||||
mcp_tools.insert(server.to_string(), vec!["get_*".to_string()]);
|
||||
}
|
||||
n.mcp_tools = Some(mcp_tools);
|
||||
}
|
||||
|
||||
node
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_node_unknown_tool_is_an_error() {
|
||||
let graph = graph_with(
|
||||
@@ -1339,6 +1383,103 @@ mod tests {
|
||||
assert!(result.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_node_mcp_tools_enabled_server_passes() {
|
||||
let graph = graph_with(
|
||||
vec![
|
||||
("l", llm_node_with_mcp_tools("l", &["github"])),
|
||||
("end", end_node("end")),
|
||||
],
|
||||
"l",
|
||||
);
|
||||
|
||||
let result = validator()
|
||||
.with_agent_context(agent_ctx(&[], &["github"]))
|
||||
.validate(&graph);
|
||||
|
||||
assert!(result.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_node_mcp_tools_unknown_server_is_an_error() {
|
||||
let graph = graph_with(
|
||||
vec![
|
||||
("l", llm_node_with_mcp_tools("l", &["slack"])),
|
||||
("end", end_node("end")),
|
||||
],
|
||||
"l",
|
||||
);
|
||||
|
||||
let result = validator()
|
||||
.with_agent_context(agent_ctx(&[], &["github"]))
|
||||
.validate(&graph);
|
||||
|
||||
assert!(!result.is_valid());
|
||||
assert!(
|
||||
result
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.message.contains("'slack' not enabled"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_node_mcp_tools_alias_key_passes() {
|
||||
let graph = graph_with(
|
||||
vec![
|
||||
("l", llm_node_with_mcp_tools("l", &["gh"])),
|
||||
("end", end_node("end")),
|
||||
],
|
||||
"l",
|
||||
);
|
||||
let mut ctx = agent_ctx(&[], &["github-mcp"]);
|
||||
let mut app = AppConfig::default();
|
||||
app.mapping_mcp_servers
|
||||
.insert("gh".to_string(), "github-mcp".to_string());
|
||||
ctx.app_config = Arc::new(app);
|
||||
|
||||
let result = validator().with_agent_context(ctx).validate(&graph);
|
||||
|
||||
assert!(result.is_valid(), "errors: {:?}", result.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_node_mcp_tools_key_matching_alias_expansion_passes() {
|
||||
let graph = graph_with(
|
||||
vec![
|
||||
("l", llm_node_with_mcp_tools("l", &["github-mcp"])),
|
||||
("end", end_node("end")),
|
||||
],
|
||||
"l",
|
||||
);
|
||||
let mut ctx = agent_ctx(&[], &["gh"]);
|
||||
let mut app = AppConfig::default();
|
||||
app.mapping_mcp_servers
|
||||
.insert("gh".to_string(), "github-mcp".to_string());
|
||||
ctx.app_config = Arc::new(app);
|
||||
|
||||
let result = validator().with_agent_context(ctx).validate(&graph);
|
||||
|
||||
assert!(result.is_valid(), "errors: {:?}", result.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_node_mcp_tools_with_all_sentinel_passes() {
|
||||
let graph = graph_with(
|
||||
vec![
|
||||
("l", llm_node_with_mcp_tools("l", &["github"])),
|
||||
("end", end_node("end")),
|
||||
],
|
||||
"l",
|
||||
);
|
||||
|
||||
let result = validator()
|
||||
.with_agent_context(agent_ctx(&[], &["all"]))
|
||||
.validate(&graph);
|
||||
|
||||
assert!(result.is_valid(), "errors: {:?}", result.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_node_unknown_model_is_an_error() {
|
||||
let graph = graph_with(
|
||||
|
||||
+4
-4
@@ -29,7 +29,7 @@ use crate::config::{
|
||||
install_builtins, list_agents, load_env_file, macro_execute, sync_models,
|
||||
};
|
||||
use crate::config::{memory, paths};
|
||||
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
||||
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
||||
use crate::mcp::McpServersConfig;
|
||||
use crate::render::{prompt_theme, render_error};
|
||||
use crate::repl::Repl;
|
||||
@@ -380,8 +380,8 @@ async fn run(
|
||||
.await?;
|
||||
} else {
|
||||
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
||||
if let Some(prompt) = &cli.prompt {
|
||||
ctx.use_prompt(app.as_ref(), prompt)?;
|
||||
if let Some(prompt) = &cli.temp_role {
|
||||
ctx.use_temp_role(app.as_ref(), prompt)?;
|
||||
} else if let Some(name) = &cli.role {
|
||||
ctx.use_role(app.as_ref(), name, abort_signal.clone())
|
||||
.await?;
|
||||
@@ -595,7 +595,7 @@ async fn start_directive(
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
match check_pending_agents_guardrail(ctx) {
|
||||
match check_pending_tasks_guardrail(ctx) {
|
||||
GuardrailAction::Inject(prompt) => {
|
||||
let guardrail_input = Input::from_str(ctx, &prompt, None)?;
|
||||
return start_directive(ctx, guardrail_input, code_mode, abort_signal).await;
|
||||
|
||||
@@ -242,6 +242,7 @@ fn build_stdio(cli: &Cli, has_url: bool) -> Result<McpServer> {
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -300,6 +301,7 @@ fn build_remote(cli: &Cli, transport: McpTransportType, has_command: bool) -> Re
|
||||
url: Some(url),
|
||||
headers: (!headers.is_empty()).then_some(headers),
|
||||
oauth,
|
||||
allowed_tools: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+174
-48
@@ -1,11 +1,12 @@
|
||||
mod auth_client;
|
||||
pub(crate) mod manage;
|
||||
pub(crate) mod oauth;
|
||||
pub(crate) mod render;
|
||||
mod sse_transport;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::config::paths;
|
||||
use crate::utils::{AbortSignal, abortable_run_with_spinner};
|
||||
use crate::utils::{AbortSignal, abortable_run_with_spinner, dimmed_text};
|
||||
use crate::vault::Vault;
|
||||
use crate::vault::interpolate_secrets;
|
||||
use anyhow::Error;
|
||||
@@ -15,6 +16,7 @@ use futures_util::{StreamExt, TryStreamExt, stream};
|
||||
use http::{HeaderName, HeaderValue};
|
||||
use indexmap::IndexMap;
|
||||
use indoc::formatdoc;
|
||||
use rmcp::model::{PromptArgument, ServerCapabilities};
|
||||
use rmcp::service::RunningService;
|
||||
use rmcp::transport::StreamableHttpClientTransport;
|
||||
use rmcp::transport::TokioChildProcess;
|
||||
@@ -34,27 +36,97 @@ use tokio::process::Command;
|
||||
pub const MCP_INVOKE_META_FUNCTION_NAME_PREFIX: &str = "mcp_invoke";
|
||||
pub const MCP_SEARCH_META_FUNCTION_NAME_PREFIX: &str = "mcp_search";
|
||||
pub const MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX: &str = "mcp_describe";
|
||||
pub const MCP_READ_META_FUNCTION_NAME_PREFIX: &str = "mcp_read";
|
||||
pub const MCP_PROMPT_META_FUNCTION_NAME_PREFIX: &str = "mcp_prompt";
|
||||
|
||||
pub const MCP_META_FUNCTION_PREFIXES: [&str; 5] = [
|
||||
MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_READ_META_FUNCTION_NAME_PREFIX,
|
||||
MCP_PROMPT_META_FUNCTION_NAME_PREFIX,
|
||||
];
|
||||
|
||||
pub fn is_mcp_meta_function(name: &str) -> bool {
|
||||
MCP_META_FUNCTION_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| name.starts_with(prefix))
|
||||
}
|
||||
|
||||
pub fn mcp_meta_function_names(server: &str) -> Vec<String> {
|
||||
MCP_META_FUNCTION_PREFIXES
|
||||
.iter()
|
||||
.map(|prefix| format!("{prefix}_{server}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub type ConnectedServer = RunningService<RoleClient, ()>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpServerFeatures {
|
||||
pub name: String,
|
||||
pub tools: bool,
|
||||
pub resources: bool,
|
||||
pub prompts: bool,
|
||||
}
|
||||
|
||||
impl McpServerFeatures {
|
||||
pub fn from_capabilities(
|
||||
name: impl Into<String>,
|
||||
capabilities: Option<&ServerCapabilities>,
|
||||
) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
tools: capabilities.is_none_or(|c| c.tools.is_some()),
|
||||
resources: capabilities.is_some_and(|c| c.resources.is_some()),
|
||||
prompts: capabilities.is_some_and(|c| c.prompts.is_some()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CatalogItemKind {
|
||||
#[default]
|
||||
Tool,
|
||||
Resource,
|
||||
ResourceTemplate,
|
||||
Prompt,
|
||||
}
|
||||
|
||||
impl CatalogItemKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Tool => "tool",
|
||||
Self::Resource => "resource",
|
||||
Self::ResourceTemplate => "resource_template",
|
||||
Self::Prompt => "prompt",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CatalogItemKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct CatalogItem {
|
||||
pub kind: CatalogItemKind,
|
||||
pub name: String,
|
||||
pub server: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ServerCatalog {
|
||||
items: HashMap<String, CatalogItem>,
|
||||
}
|
||||
|
||||
impl Clone for ServerCatalog {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
items: self.items.clone(),
|
||||
}
|
||||
}
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub uri: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<Vec<PromptArgument>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audience: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -94,6 +166,8 @@ pub(crate) struct McpServer {
|
||||
pub headers: Option<IndexMap<String, String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oauth: Option<McpOAuthConfig>,
|
||||
#[serde(rename = "allowedTools", skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
@@ -105,6 +179,15 @@ impl McpServer {
|
||||
}
|
||||
|
||||
pub fn validate(&self, name: &str) -> Result<()> {
|
||||
if let Some(tools) = &self.allowed_tools
|
||||
&& tools.is_empty()
|
||||
{
|
||||
let message = format!(
|
||||
"MCP server '{name}' has an empty \"allowedTools\" list, so none of its tools will be callable"
|
||||
);
|
||||
warn!("{message}");
|
||||
eprintln!("{}", dimmed_text(&message));
|
||||
}
|
||||
if self.is_remote() {
|
||||
let type_label = match self.transport_type {
|
||||
McpTransportType::Http => "http",
|
||||
@@ -160,7 +243,6 @@ pub struct McpRegistry {
|
||||
log_path: Option<PathBuf>,
|
||||
config: Option<McpServersConfig>,
|
||||
servers: HashMap<String, Arc<ConnectedServer>>,
|
||||
catalogs: HashMap<String, ServerCatalog>,
|
||||
}
|
||||
|
||||
impl McpRegistry {
|
||||
@@ -303,7 +385,7 @@ impl McpRegistry {
|
||||
|
||||
debug!("Starting selected MCP servers: {:?}", ids_to_start);
|
||||
|
||||
let results: Vec<Option<(String, Arc<ConnectedServer>, ServerCatalog)>> = stream::iter(
|
||||
let results: Vec<Option<(String, Arc<ConnectedServer>)>> = stream::iter(
|
||||
ids_to_start
|
||||
.into_iter()
|
||||
.map(|id| async { self.start_server(id).await }),
|
||||
@@ -312,18 +394,14 @@ impl McpRegistry {
|
||||
.try_collect()
|
||||
.await?;
|
||||
|
||||
for (id, server, catalog) in results.into_iter().flatten() {
|
||||
self.servers.insert(id.clone(), server);
|
||||
self.catalogs.insert(id, catalog);
|
||||
for (id, server) in results.into_iter().flatten() {
|
||||
self.servers.insert(id, server);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_server(
|
||||
&self,
|
||||
id: String,
|
||||
) -> Result<Option<(String, Arc<ConnectedServer>, ServerCatalog)>> {
|
||||
async fn start_server(&self, id: String) -> Result<Option<(String, Arc<ConnectedServer>)>> {
|
||||
let spec = self
|
||||
.config
|
||||
.as_ref()
|
||||
@@ -347,30 +425,9 @@ impl McpRegistry {
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let tools = service.list_tools(None).await?;
|
||||
debug!("Available tools for MCP server {id}: {tools:?}");
|
||||
|
||||
let mut items_vec = Vec::new();
|
||||
for t in tools.tools {
|
||||
let name = t.name.to_string();
|
||||
let description = t.description.unwrap_or_default().to_string();
|
||||
items_vec.push(CatalogItem {
|
||||
name,
|
||||
server: id.clone(),
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
let mut items_map = HashMap::new();
|
||||
items_vec.into_iter().for_each(|it| {
|
||||
items_map.insert(it.name.clone(), it);
|
||||
});
|
||||
|
||||
let catalog = ServerCatalog { items: items_map };
|
||||
|
||||
info!("Started MCP server: {id}");
|
||||
|
||||
Ok(Some((id.to_string(), service, catalog)))
|
||||
Ok(Some((id, service)))
|
||||
}
|
||||
|
||||
fn resolve_server_ids(&self, enabled_mcp_servers: Option<Vec<String>>) -> Vec<String> {
|
||||
@@ -398,8 +455,21 @@ impl McpRegistry {
|
||||
&self.servers
|
||||
}
|
||||
|
||||
pub fn list_started_servers(&self) -> Vec<String> {
|
||||
self.servers.keys().cloned().collect()
|
||||
pub fn server_features(&self) -> Vec<McpServerFeatures> {
|
||||
let mut features: Vec<McpServerFeatures> = self
|
||||
.servers
|
||||
.iter()
|
||||
.map(|(name, handle)| {
|
||||
let info = handle.peer_info();
|
||||
McpServerFeatures::from_capabilities(
|
||||
name.as_str(),
|
||||
info.as_ref().map(|info| &info.capabilities),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
features.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
features
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
@@ -743,6 +813,7 @@ mod tests {
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,6 +827,7 @@ mod tests {
|
||||
url: Some(url.to_string()),
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,6 +841,7 @@ mod tests {
|
||||
url: Some(url.to_string()),
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,6 +874,7 @@ mod tests {
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -819,6 +893,7 @@ mod tests {
|
||||
url: Some("http://localhost".into()),
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -839,6 +914,7 @@ mod tests {
|
||||
url: None,
|
||||
headers: Some(headers),
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -864,6 +940,7 @@ mod tests {
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -882,6 +959,7 @@ mod tests {
|
||||
url: Some("http://localhost".into()),
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -900,6 +978,7 @@ mod tests {
|
||||
url: Some("http://localhost".into()),
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -918,6 +997,7 @@ mod tests {
|
||||
url: Some("http://localhost".into()),
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -943,6 +1023,7 @@ mod tests {
|
||||
url: None,
|
||||
headers: None,
|
||||
oauth: None,
|
||||
allowed_tools: None,
|
||||
};
|
||||
|
||||
let err = spec.validate("test").unwrap_err();
|
||||
@@ -1161,7 +1242,7 @@ mod tests {
|
||||
let registry = McpRegistry::default();
|
||||
|
||||
assert!(registry.is_empty());
|
||||
assert!(registry.list_started_servers().is_empty());
|
||||
assert!(registry.server_features().is_empty());
|
||||
assert!(registry.mcp_config().is_none());
|
||||
assert!(registry.log_path().is_none());
|
||||
}
|
||||
@@ -1185,6 +1266,51 @@ mod tests {
|
||||
assert_eq!(MCP_INVOKE_META_FUNCTION_NAME_PREFIX, "mcp_invoke");
|
||||
assert_eq!(MCP_SEARCH_META_FUNCTION_NAME_PREFIX, "mcp_search");
|
||||
assert_eq!(MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, "mcp_describe");
|
||||
assert_eq!(MCP_READ_META_FUNCTION_NAME_PREFIX, "mcp_read");
|
||||
assert_eq!(MCP_PROMPT_META_FUNCTION_NAME_PREFIX, "mcp_prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_mcp_meta_function_classifies_names() {
|
||||
assert!(is_mcp_meta_function("mcp_invoke_github"));
|
||||
assert!(is_mcp_meta_function("mcp_search_github"));
|
||||
assert!(is_mcp_meta_function("mcp_describe_github"));
|
||||
assert!(is_mcp_meta_function("mcp_read_github"));
|
||||
assert!(is_mcp_meta_function("mcp_prompt_github"));
|
||||
assert!(!is_mcp_meta_function("mcp_gateway_tool"));
|
||||
assert!(!is_mcp_meta_function("fs_read"));
|
||||
assert!(!is_mcp_meta_function(""));
|
||||
assert!(!is_mcp_meta_function("mcp_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_function_prefixes_are_not_prefixes_of_each_other() {
|
||||
for (i, a) in MCP_META_FUNCTION_PREFIXES.iter().enumerate() {
|
||||
for (j, b) in MCP_META_FUNCTION_PREFIXES.iter().enumerate() {
|
||||
if i != j {
|
||||
assert!(!b.starts_with(a), "{a} is a prefix of {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_mcp_meta_function_preserves_lax_prefix_matching() {
|
||||
assert!(is_mcp_meta_function("mcp_invoker_x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_meta_function_names_returns_all_prefixes_in_order() {
|
||||
assert_eq!(
|
||||
mcp_meta_function_names("github"),
|
||||
vec![
|
||||
"mcp_invoke_github",
|
||||
"mcp_search_github",
|
||||
"mcp_describe_github",
|
||||
"mcp_read_github",
|
||||
"mcp_prompt_github",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,896 @@
|
||||
//! Content policy for MCP resource and tool content: UTF-8-boundary-safe text
|
||||
//! paging, grep-style pattern filtering, and spill-to-disk for binary blobs.
|
||||
|
||||
use crate::config::paths;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::read::DecoderReader;
|
||||
use fancy_regex::Regex;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::error::Error;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::SystemTime;
|
||||
use std::{fmt, io};
|
||||
|
||||
/// Default page size when the caller does not specify `max_bytes`.
|
||||
pub const DEFAULT_TEXT_MAX_BYTES: usize = 51_200;
|
||||
/// Hard upper bound on a single text slice regardless of requested `max_bytes`.
|
||||
pub const TEXT_MAX_BYTES_CLAMP: usize = 204_800;
|
||||
/// Maximum decoded size of a base64 blob before rendering is refused.
|
||||
pub const BLOB_DECODE_CEILING_BYTES: usize = 50 * 1024 * 1024;
|
||||
/// Total size bound for the spill tree; oldest files are evicted beyond it.
|
||||
pub const SPILL_DIR_MAX_BYTES: u64 = 512 * 1024 * 1024;
|
||||
/// Byte bound on server-supplied metadata strings (uri, mime type) copied into output.
|
||||
pub const METADATA_MAX_BYTES: usize = 4096;
|
||||
|
||||
const PATTERN_CONTEXT_LINES: usize = 2;
|
||||
const HUNK_SEPARATOR: &str = "--";
|
||||
|
||||
const MIME_EXTENSIONS: &[(&str, &str)] = &[
|
||||
("application/gzip", "gz"),
|
||||
("application/json", "json"),
|
||||
("application/pdf", "pdf"),
|
||||
("application/zip", "zip"),
|
||||
("audio/mpeg", "mp3"),
|
||||
("image/gif", "gif"),
|
||||
("image/jpeg", "jpg"),
|
||||
("image/png", "png"),
|
||||
("image/webp", "webp"),
|
||||
("text/csv", "csv"),
|
||||
("video/mp4", "mp4"),
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RenderError {
|
||||
InvalidPattern { pattern: String, error: String },
|
||||
DecodedSizeExceeded,
|
||||
InvalidBase64(String),
|
||||
Io(io::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for RenderError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidPattern { pattern, error } => write!(
|
||||
f,
|
||||
"Invalid filter pattern '{pattern}': {error}. Provide a valid regex; \
|
||||
lines matching it are returned with {PATTERN_CONTEXT_LINES} lines of context."
|
||||
),
|
||||
Self::DecodedSizeExceeded => write!(
|
||||
f,
|
||||
"Decoded blob exceeds BLOB_DECODE_CEILING_BYTES ({} MiB); refusing to render it",
|
||||
BLOB_DECODE_CEILING_BYTES / (1024 * 1024)
|
||||
),
|
||||
Self::InvalidBase64(error) => write!(f, "Invalid base64 in blob content: {error}"),
|
||||
Self::Io(error) => write!(f, "Failed to spill blob to disk: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for RenderError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(error) => Some(error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for RenderError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RenderedText {
|
||||
pub text: String,
|
||||
pub truncated: bool,
|
||||
pub total_bytes: usize,
|
||||
pub next_offset: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RenderedBlob {
|
||||
Text(String),
|
||||
Spilled(SpillMetadata),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SpillMetadata {
|
||||
pub spilled: bool,
|
||||
pub path: PathBuf,
|
||||
pub mime_type: Option<String>,
|
||||
pub sniffed: bool,
|
||||
pub size_bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// Pages `text` with UTF-8-boundary-safe slicing. When `pattern` is set, the
|
||||
/// text is first reduced to matching lines plus context (grep-style, with
|
||||
/// 1-based line-number prefixes), and all offset/size math operates on that
|
||||
/// filtered stream.
|
||||
pub fn render_text(
|
||||
text: &str,
|
||||
pattern: Option<&str>,
|
||||
offset: usize,
|
||||
max_bytes: Option<usize>,
|
||||
) -> Result<RenderedText, RenderError> {
|
||||
let filtered = match pattern {
|
||||
Some(pattern) => Some(filter_lines(text, pattern)?),
|
||||
None => None,
|
||||
};
|
||||
let stream = filtered.as_deref().unwrap_or(text);
|
||||
let max_bytes = max_bytes
|
||||
.unwrap_or(DEFAULT_TEXT_MAX_BYTES)
|
||||
.min(TEXT_MAX_BYTES_CLAMP);
|
||||
let total_bytes = stream.len();
|
||||
let mut start = offset.min(total_bytes);
|
||||
while !stream.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
let mut end = start.saturating_add(max_bytes).min(total_bytes);
|
||||
while !stream.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
// A max_bytes smaller than one codepoint would produce an empty page with
|
||||
// next_offset == offset, stalling paging; always advance by at least one.
|
||||
if end == start && start < total_bytes {
|
||||
end += 1;
|
||||
while !stream.is_char_boundary(end) {
|
||||
end += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let truncated = end < total_bytes;
|
||||
Ok(RenderedText {
|
||||
text: stream[start..end].to_string(),
|
||||
truncated,
|
||||
total_bytes,
|
||||
next_offset: truncated.then_some(end),
|
||||
})
|
||||
}
|
||||
|
||||
/// Decodes a base64 blob, returning it as text when it is valid UTF-8 and
|
||||
/// spilling it under `cache_dir()/mcp-resources/<server>/` otherwise.
|
||||
pub fn render_blob(
|
||||
b64: &str,
|
||||
claimed_mime: Option<&str>,
|
||||
server: &str,
|
||||
) -> Result<RenderedBlob, RenderError> {
|
||||
let spill_base = paths::cache_dir().join("mcp-resources");
|
||||
render_blob_at(b64, claimed_mime, server, &spill_base)
|
||||
}
|
||||
|
||||
pub fn render_blob_at(
|
||||
b64: &str,
|
||||
claimed_mime: Option<&str>,
|
||||
server: &str,
|
||||
spill_base: &Path,
|
||||
) -> Result<RenderedBlob, RenderError> {
|
||||
let decoded = decode_base64_bounded(b64)?;
|
||||
let decoded = match String::from_utf8(decoded) {
|
||||
Ok(text) => return Ok(RenderedBlob::Text(text)),
|
||||
Err(error) => error.into_bytes(),
|
||||
};
|
||||
let sha256 = format!("{:x}", Sha256::digest(&decoded));
|
||||
let dir = spill_base.join(sanitize_server(server));
|
||||
fs::create_dir_all(&dir)?;
|
||||
let path = dir.join(format!("{sha256}.{}", extension_for_mime(claimed_mime)));
|
||||
|
||||
// Writes land in a temp file and are renamed into place, so a visible
|
||||
// file at the final path is always complete and the dedup check below is
|
||||
// race-safe across processes (same sha means same content).
|
||||
if !path.exists() {
|
||||
static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
let temp = dir.join(format!(
|
||||
"{sha256}.tmp-{}-{}",
|
||||
std::process::id(),
|
||||
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
options.mode(0o600);
|
||||
let written = options
|
||||
.open(&temp)
|
||||
.and_then(|mut file| file.write_all(&decoded))
|
||||
.and_then(|()| fs::rename(&temp, &path));
|
||||
if let Err(error) = written {
|
||||
let _ = fs::remove_file(&temp);
|
||||
return Err(RenderError::Io(error));
|
||||
}
|
||||
}
|
||||
enforce_spill_bound(spill_base, SPILL_DIR_MAX_BYTES, &path);
|
||||
|
||||
Ok(RenderedBlob::Spilled(SpillMetadata {
|
||||
spilled: true,
|
||||
path,
|
||||
mime_type: claimed_mime.map(str::to_string),
|
||||
sniffed: false,
|
||||
size_bytes: decoded.len() as u64,
|
||||
sha256,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Truncates `text` to at most `max_bytes`, rounding the cut point back to a
|
||||
/// UTF-8 character boundary.
|
||||
pub fn truncate_utf8(text: &str, max_bytes: usize) -> &str {
|
||||
if text.len() <= max_bytes {
|
||||
return text;
|
||||
}
|
||||
let mut end = max_bytes;
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&text[..end]
|
||||
}
|
||||
|
||||
/// Bounds a server-supplied metadata string to [`METADATA_MAX_BYTES`],
|
||||
/// appending a marker citing the constant when the input is truncated.
|
||||
pub fn clamp_metadata(text: &str) -> String {
|
||||
if text.len() <= METADATA_MAX_BYTES {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
let clamped = truncate_utf8(text, METADATA_MAX_BYTES);
|
||||
format!("{clamped} [truncated: exceeds METADATA_MAX_BYTES ({METADATA_MAX_BYTES} bytes)]")
|
||||
}
|
||||
|
||||
fn filter_lines(text: &str, pattern: &str) -> Result<String, RenderError> {
|
||||
let regex = Regex::new(pattern).map_err(|error| RenderError::InvalidPattern {
|
||||
pattern: pattern.to_string(),
|
||||
error: error.to_string(),
|
||||
})?;
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
// fancy_regex can also fail at match time (backtracking limits); treat
|
||||
// that as a non-match rather than failing the whole render.
|
||||
let is_match: Vec<bool> = lines
|
||||
.iter()
|
||||
.map(|line| regex.is_match(line).unwrap_or(false))
|
||||
.collect();
|
||||
|
||||
let mut keep = vec![false; lines.len()];
|
||||
for (i, _) in is_match.iter().enumerate().filter(|&(_, matched)| *matched) {
|
||||
let start = i.saturating_sub(PATTERN_CONTEXT_LINES);
|
||||
let end = (i + PATTERN_CONTEXT_LINES).min(lines.len() - 1);
|
||||
keep[start..=end].fill(true);
|
||||
}
|
||||
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut prev_kept: Option<usize> = None;
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if !keep[i] {
|
||||
continue;
|
||||
}
|
||||
if prev_kept.is_some_and(|prev| i > prev + 1) {
|
||||
out.push(HUNK_SEPARATOR.to_string());
|
||||
}
|
||||
let marker = if is_match[i] { ':' } else { '-' };
|
||||
out.push(format!("{}{marker}{line}", i + 1));
|
||||
prev_kept = Some(i);
|
||||
}
|
||||
|
||||
Ok(out.join("\n"))
|
||||
}
|
||||
|
||||
fn decode_base64_bounded(b64: &str) -> Result<Vec<u8>, RenderError> {
|
||||
// The encoded length puts a lower bound on the decoded size; reject
|
||||
// inputs that bound already proves oversized before decoding anything.
|
||||
let min_decoded = (b64.len() / 4).saturating_mul(3).saturating_sub(2);
|
||||
if min_decoded > BLOB_DECODE_CEILING_BYTES {
|
||||
return Err(RenderError::DecodedSizeExceeded);
|
||||
}
|
||||
|
||||
let mut reader = DecoderReader::new(b64.as_bytes(), &STANDARD);
|
||||
let mut decoded = Vec::new();
|
||||
let mut chunk = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut chunk) {
|
||||
Ok(0) => return Ok(decoded),
|
||||
Ok(n) => {
|
||||
if decoded.len() + n > BLOB_DECODE_CEILING_BYTES {
|
||||
return Err(RenderError::DecodedSizeExceeded);
|
||||
}
|
||||
decoded.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
Err(error) => return Err(RenderError::InvalidBase64(error.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a server-controlled mime type to a spill-file extension via an exact
|
||||
/// allowlist lookup; anything unrecognized falls back to `bin`.
|
||||
fn extension_for_mime(mime: Option<&str>) -> &'static str {
|
||||
let Some(mime) = mime else {
|
||||
return "bin";
|
||||
};
|
||||
let bare = mime
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let ext = MIME_EXTENSIONS
|
||||
.iter()
|
||||
.find(|(known, _)| *known == bare)
|
||||
.map(|(_, ext)| *ext)
|
||||
.unwrap_or("bin");
|
||||
let safe = !ext.is_empty()
|
||||
&& ext.len() <= 8
|
||||
&& ext
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
|
||||
|
||||
if safe { ext } else { "bin" }
|
||||
}
|
||||
|
||||
fn sanitize_server(server: &str) -> String {
|
||||
let mut sanitized: String = server
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.take(64)
|
||||
.collect();
|
||||
// Windows strips trailing dots at create time, which would make the
|
||||
// constructed path disagree with the on-disk name.
|
||||
while sanitized.ends_with('.') {
|
||||
sanitized.pop();
|
||||
}
|
||||
if sanitized.is_empty() {
|
||||
return "_".to_string();
|
||||
}
|
||||
// Windows reserves device names (bare or with any extension).
|
||||
let stem = sanitized.split('.').next().unwrap_or("");
|
||||
if is_windows_reserved(stem) {
|
||||
sanitized.insert(0, '_');
|
||||
}
|
||||
sanitized
|
||||
}
|
||||
|
||||
fn is_windows_reserved(stem: &str) -> bool {
|
||||
let lower = stem.to_ascii_lowercase();
|
||||
matches!(lower.as_str(), "con" | "prn" | "aux" | "nul")
|
||||
|| (lower.len() == 4
|
||||
&& (lower.starts_with("com") || lower.starts_with("lpt"))
|
||||
&& matches!(lower.as_bytes()[3], b'1'..=b'9'))
|
||||
}
|
||||
|
||||
struct SpillEntry {
|
||||
path: PathBuf,
|
||||
size: u64,
|
||||
modified: SystemTime,
|
||||
}
|
||||
|
||||
fn enforce_spill_bound(base: &Path, max_total: u64, protect: &Path) {
|
||||
let mut entries = Vec::new();
|
||||
collect_spill_files(base, &mut entries);
|
||||
evict_oldest(entries, max_total, protect);
|
||||
}
|
||||
|
||||
/// Best-effort eviction: the spill dir is shared across processes, so a file
|
||||
/// vanishing underneath us (`NotFound`) is expected and never fails the spill.
|
||||
fn evict_oldest(mut entries: Vec<SpillEntry>, max_total: u64, protect: &Path) {
|
||||
let mut total: u64 = entries.iter().map(|entry| entry.size).sum();
|
||||
if total <= max_total {
|
||||
return;
|
||||
}
|
||||
entries.sort_by_key(|entry| entry.modified);
|
||||
for entry in &entries {
|
||||
if total <= max_total {
|
||||
break;
|
||||
}
|
||||
|
||||
// Filenames are content-hashed, so name equality is sufficient and
|
||||
// survives filesystems that normalize directory names (case folding,
|
||||
// trailing-dot stripping) where a full-path comparison would miss.
|
||||
if entry.path.file_name() == protect.file_name() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match fs::remove_file(&entry.path) {
|
||||
Ok(()) => total -= entry.size,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => total -= entry.size,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_spill_files(dir: &Path, out: &mut Vec<SpillEntry>) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(metadata) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
if metadata.is_dir() {
|
||||
collect_spill_files(&path, out);
|
||||
} else if metadata.is_file() {
|
||||
out.push(SpillEntry {
|
||||
path,
|
||||
size: metadata.len(),
|
||||
modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use std::env;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
fn with_spill_base<F: FnOnce(&Path)>(f: F) {
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
let unique = format!(
|
||||
"{}-{}",
|
||||
process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
let base = env::temp_dir().join(format!("coyote-render-test-{unique}"));
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
f(&base);
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
fn set_mtime(path: &Path, secs_after_epoch: u64) {
|
||||
let file = OpenOptions::new().write(true).open(path).unwrap();
|
||||
file.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(secs_after_epoch))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_spill_file(dir: &Path, name: &str, len: usize, mtime_secs: u64) -> PathBuf {
|
||||
let path = dir.join(name);
|
||||
fs::write(&path, vec![0u8; len]).unwrap();
|
||||
set_mtime(&path, mtime_secs);
|
||||
path
|
||||
}
|
||||
|
||||
const TEN_LINES: &str = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten";
|
||||
|
||||
#[test]
|
||||
fn slices_basic_ascii_page() {
|
||||
let rendered = render_text("hello world", None, 0, Some(5)).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "hello");
|
||||
assert!(rendered.truncated);
|
||||
assert_eq!(rendered.total_bytes, 11);
|
||||
assert_eq!(rendered.next_offset, Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_mid_codepoint_rounds_forward() {
|
||||
// 'é' occupies bytes 1..3; offset 2 lands inside it.
|
||||
let rendered = render_text("héllo", None, 2, None).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "llo");
|
||||
assert!(!rendered.truncated);
|
||||
assert_eq!(rendered.next_offset, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_mid_codepoint_rounds_backward() {
|
||||
// 'é' occupies bytes 1..3; offset 0 + max_bytes 2 lands inside it.
|
||||
let rendered = render_text("aé", None, 0, Some(2)).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "a");
|
||||
assert!(rendered.truncated);
|
||||
assert_eq!(rendered.total_bytes, 3);
|
||||
assert_eq!(rendered.next_offset, Some(1));
|
||||
|
||||
let rest = render_text("aé", None, 1, Some(2)).unwrap();
|
||||
|
||||
assert_eq!(rest.text, "é");
|
||||
assert!(!rest.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_bytes_below_one_codepoint_still_advances() {
|
||||
// 'é' is 2 bytes; max_bytes 1 must not stall at next_offset == offset.
|
||||
let rendered = render_text("éa", None, 0, Some(1)).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "é");
|
||||
assert!(rendered.truncated);
|
||||
assert_eq!(rendered.total_bytes, 3);
|
||||
assert_eq!(rendered.next_offset, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_past_eof_returns_empty() {
|
||||
let rendered = render_text("short", None, 100, None).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "");
|
||||
assert!(!rendered.truncated);
|
||||
assert_eq!(rendered.total_bytes, 5);
|
||||
assert_eq!(rendered.next_offset, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_fit_is_not_truncated() {
|
||||
let rendered = render_text("exact", None, 0, Some(5)).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "exact");
|
||||
assert!(!rendered.truncated);
|
||||
assert_eq!(rendered.next_offset, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_max_bytes_is_default_text_max_bytes() {
|
||||
let text = "a".repeat(DEFAULT_TEXT_MAX_BYTES + 1);
|
||||
|
||||
let rendered = render_text(&text, None, 0, None).unwrap();
|
||||
|
||||
assert_eq!(rendered.text.len(), DEFAULT_TEXT_MAX_BYTES);
|
||||
assert!(rendered.truncated);
|
||||
assert_eq!(rendered.next_offset, Some(DEFAULT_TEXT_MAX_BYTES));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_bytes_above_clamp_is_clamped() {
|
||||
let text = "a".repeat(TEXT_MAX_BYTES_CLAMP + 1);
|
||||
|
||||
let rendered = render_text(&text, None, 0, Some(usize::MAX)).unwrap();
|
||||
|
||||
assert_eq!(rendered.text.len(), TEXT_MAX_BYTES_CLAMP);
|
||||
assert!(rendered.truncated);
|
||||
assert_eq!(rendered.next_offset, Some(TEXT_MAX_BYTES_CLAMP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_utf8_rounds_back_to_char_boundary() {
|
||||
// 'é' occupies bytes 1..3; a cut at byte 2 lands inside it.
|
||||
assert_eq!(truncate_utf8("aé", 2), "a");
|
||||
assert_eq!(truncate_utf8("aé", 3), "aé");
|
||||
assert_eq!(truncate_utf8("abc", 10), "abc");
|
||||
assert_eq!(truncate_utf8("abc", 0), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_metadata_appends_marker_only_when_oversized() {
|
||||
assert_eq!(clamp_metadata("text/plain"), "text/plain");
|
||||
|
||||
let long = "u".repeat(METADATA_MAX_BYTES + 1);
|
||||
|
||||
let clamped = clamp_metadata(&long);
|
||||
|
||||
assert!(clamped.starts_with(&"u".repeat(METADATA_MAX_BYTES)));
|
||||
assert!(clamped.contains("METADATA_MAX_BYTES"));
|
||||
assert!(clamped.contains(&METADATA_MAX_BYTES.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_emits_matches_with_context_and_line_numbers() {
|
||||
let rendered = render_text(TEN_LINES, Some("^five$"), 0, None).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "3-three\n4-four\n5:five\n6-six\n7-seven");
|
||||
assert!(!rendered.truncated);
|
||||
assert_eq!(rendered.total_bytes, rendered.text.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_separates_disjoint_hunks() {
|
||||
let rendered = render_text(TEN_LINES, Some("^(two|nine)$"), 0, None).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rendered.text,
|
||||
"1-one\n2:two\n3-three\n4-four\n--\n7-seven\n8-eight\n9:nine\n10-ten"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_merges_adjacent_hunks_without_duplicates() {
|
||||
let rendered = render_text(TEN_LINES, Some("^(two|six)$"), 0, None).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rendered.text,
|
||||
"1-one\n2:two\n3-three\n4-four\n5-five\n6:six\n7-seven\n8-eight"
|
||||
);
|
||||
assert!(!rendered.text.contains(HUNK_SEPARATOR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_paging_walks_the_filtered_stream() {
|
||||
let full = render_text(TEN_LINES, Some("^t"), 0, None).unwrap();
|
||||
assert!(!full.truncated);
|
||||
|
||||
let mut assembled = String::new();
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
let page = render_text(TEN_LINES, Some("^t"), offset, Some(7)).unwrap();
|
||||
assert_eq!(page.total_bytes, full.text.len());
|
||||
assembled.push_str(&page.text);
|
||||
match page.next_offset {
|
||||
Some(next) => offset = next,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(assembled, full.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_with_no_matches_returns_empty() {
|
||||
let rendered = render_text(TEN_LINES, Some("^zebra$"), 0, None).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "");
|
||||
assert_eq!(rendered.total_bytes, 0);
|
||||
assert!(!rendered.truncated);
|
||||
assert_eq!(rendered.next_offset, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_pattern_is_a_teaching_error() {
|
||||
let parse_error = Regex::new("(").unwrap_err().to_string();
|
||||
|
||||
let err = render_text("text", Some("("), 0, None).unwrap_err();
|
||||
|
||||
assert!(matches!(err, RenderError::InvalidPattern { .. }));
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("'('"));
|
||||
assert!(message.contains(&parse_error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_blob_decodes_to_text_without_spilling() {
|
||||
with_spill_base(|base| {
|
||||
let b64 = STANDARD.encode("hello ✓ world");
|
||||
|
||||
let rendered = render_blob_at(&b64, Some("text/plain"), "srv", base).unwrap();
|
||||
|
||||
let RenderedBlob::Text(text) = rendered else {
|
||||
panic!("expected text variant");
|
||||
};
|
||||
assert_eq!(text, "hello ✓ world");
|
||||
assert_eq!(fs::read_dir(base).unwrap().count(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_blob_spills_with_metadata_and_0600_perms() {
|
||||
with_spill_base(|base| {
|
||||
let data: &[u8] = &[0xff, 0xfe, 0x00, 0x88, 0x01];
|
||||
let b64 = STANDARD.encode(data);
|
||||
|
||||
let rendered = render_blob_at(&b64, Some("application/pdf"), "docs", base).unwrap();
|
||||
|
||||
let RenderedBlob::Spilled(meta) = rendered else {
|
||||
panic!("expected spilled variant");
|
||||
};
|
||||
let expected_sha = format!("{:x}", Sha256::digest(data));
|
||||
assert_eq!(meta.sha256, expected_sha);
|
||||
assert_eq!(
|
||||
meta.path,
|
||||
base.join("docs").join(format!("{expected_sha}.pdf"))
|
||||
);
|
||||
assert_eq!(meta.size_bytes, data.len() as u64);
|
||||
assert_eq!(meta.mime_type.as_deref(), Some("application/pdf"));
|
||||
assert!(!meta.sniffed);
|
||||
assert!(meta.spilled);
|
||||
assert_eq!(fs::read(&meta.path).unwrap(), data);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mode = fs::metadata(&meta.path).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_ceiling_rejects_oversized_blob() {
|
||||
with_spill_base(|base| {
|
||||
// base64 of 51 MiB of zero bytes is just a repeated-'A' string.
|
||||
let encoded = "A".repeat(51 * 1024 * 1024 / 3 * 4);
|
||||
|
||||
let err = render_blob_at(&encoded, None, "srv", base).unwrap_err();
|
||||
|
||||
assert!(matches!(err, RenderError::DecodedSizeExceeded));
|
||||
assert!(err.to_string().contains("BLOB_DECODE_CEILING_BYTES"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_base64_is_rejected() {
|
||||
with_spill_base(|base| {
|
||||
let err = render_blob_at("!!!not base64!!!", None, "srv", base).unwrap_err();
|
||||
|
||||
assert!(matches!(err, RenderError::InvalidBase64(_)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_dedup_returns_same_path_without_rewriting() {
|
||||
with_spill_base(|base| {
|
||||
let data: &[u8] = &[0xff, 0x01, 0x02];
|
||||
let b64 = STANDARD.encode(data);
|
||||
|
||||
let RenderedBlob::Spilled(first) = render_blob_at(&b64, None, "srv", base).unwrap()
|
||||
else {
|
||||
panic!("expected spilled variant");
|
||||
};
|
||||
fs::write(&first.path, b"sentinel").unwrap();
|
||||
|
||||
let RenderedBlob::Spilled(second) = render_blob_at(&b64, None, "srv", base).unwrap()
|
||||
else {
|
||||
panic!("expected spilled variant");
|
||||
};
|
||||
|
||||
assert_eq!(second.path, first.path);
|
||||
assert_eq!(second.sha256, first.sha256);
|
||||
assert_eq!(fs::read(&second.path).unwrap(), b"sentinel");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_metadata_serializes_spilled_true() {
|
||||
with_spill_base(|base| {
|
||||
let b64 = STANDARD.encode([0xffu8, 0x00]);
|
||||
|
||||
let RenderedBlob::Spilled(meta) =
|
||||
render_blob_at(&b64, Some("image/png"), "srv", base).unwrap()
|
||||
else {
|
||||
panic!("expected spilled variant");
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&meta).unwrap();
|
||||
assert_eq!(value["spilled"], serde_json::Value::Bool(true));
|
||||
assert_eq!(value["sniffed"], serde_json::Value::Bool(false));
|
||||
assert_eq!(value["sha256"].as_str(), Some(meta.sha256.as_str()));
|
||||
assert_eq!(value["mime_type"].as_str(), Some("image/png"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_allowlist_normalizes_and_defaults_to_bin() {
|
||||
assert_eq!(extension_for_mime(Some("application/pdf")), "pdf");
|
||||
assert_eq!(extension_for_mime(Some("image/png")), "png");
|
||||
assert_eq!(extension_for_mime(Some(" TEXT/CSV ; charset=utf-8")), "csv");
|
||||
assert_eq!(extension_for_mime(Some("../../evil")), "bin");
|
||||
assert_eq!(extension_for_mime(Some("image/png/../../x")), "bin");
|
||||
assert_eq!(extension_for_mime(Some("application/x-∞")), "bin");
|
||||
assert_eq!(extension_for_mime(Some("text/plain")), "bin");
|
||||
assert_eq!(extension_for_mime(None), "bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_server_strips_path_separators() {
|
||||
assert_eq!(sanitize_server("../evil/srv"), ".._evil_srv");
|
||||
assert_eq!(sanitize_server("srv name!"), "srv_name_");
|
||||
assert_eq!(sanitize_server(""), "_");
|
||||
assert_eq!(sanitize_server("."), "_");
|
||||
assert_eq!(sanitize_server(".."), "_");
|
||||
assert_eq!(sanitize_server("good-server_1.0"), "good-server_1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_server_escapes_windows_reserved_names() {
|
||||
assert_eq!(sanitize_server("con"), "_con");
|
||||
assert_eq!(sanitize_server("CON"), "_CON");
|
||||
assert_eq!(sanitize_server("nul.txt"), "_nul.txt");
|
||||
assert_eq!(sanitize_server("COM1"), "_COM1");
|
||||
assert_eq!(sanitize_server("lpt9"), "_lpt9");
|
||||
assert_eq!(sanitize_server("com0"), "com0");
|
||||
assert_eq!(sanitize_server("com10"), "com10");
|
||||
assert_eq!(sanitize_server("consul"), "consul");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_server_strips_trailing_dots_and_caps_length() {
|
||||
assert_eq!(sanitize_server("srv."), "srv");
|
||||
assert_eq!(sanitize_server("srv..."), "srv");
|
||||
assert_eq!(sanitize_server("..."), "_");
|
||||
let long = "a".repeat(100);
|
||||
assert_eq!(sanitize_server(&long).len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_path_confines_crafted_server_and_mime() {
|
||||
with_spill_base(|base| {
|
||||
let b64 = STANDARD.encode([0xffu8, 0x00, 0x11]);
|
||||
|
||||
let RenderedBlob::Spilled(meta) =
|
||||
render_blob_at(&b64, Some("../../evil"), "../evil/srv", base).unwrap()
|
||||
else {
|
||||
panic!("expected spilled variant");
|
||||
};
|
||||
|
||||
assert!(meta.path.starts_with(base));
|
||||
let dir_name = meta.path.parent().unwrap().file_name().unwrap();
|
||||
assert_eq!(dir_name, ".._evil_srv");
|
||||
assert_eq!(meta.path.extension().unwrap(), "bin");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_removes_oldest_files_first_across_server_dirs() {
|
||||
with_spill_base(|base| {
|
||||
let srv_a = base.join("srv-a");
|
||||
let srv_b = base.join("srv-b");
|
||||
fs::create_dir_all(&srv_a).unwrap();
|
||||
fs::create_dir_all(&srv_b).unwrap();
|
||||
let oldest = write_spill_file(&srv_a, "a.bin", 100, 100);
|
||||
let middle = write_spill_file(&srv_b, "b.bin", 100, 200);
|
||||
let newest = write_spill_file(&srv_b, "c.bin", 100, 300);
|
||||
|
||||
enforce_spill_bound(base, 150, &newest);
|
||||
|
||||
assert!(!oldest.exists());
|
||||
assert!(!middle.exists());
|
||||
assert!(newest.exists());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_skips_protected_file() {
|
||||
with_spill_base(|base| {
|
||||
let srv = base.join("srv");
|
||||
fs::create_dir_all(&srv).unwrap();
|
||||
let oldest = write_spill_file(&srv, "a.bin", 100, 100);
|
||||
let middle = write_spill_file(&srv, "b.bin", 100, 200);
|
||||
let newest = write_spill_file(&srv, "c.bin", 100, 300);
|
||||
|
||||
enforce_spill_bound(base, 250, &oldest);
|
||||
|
||||
assert!(oldest.exists());
|
||||
assert!(!middle.exists());
|
||||
assert!(newest.exists());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_under_bound_is_noop() {
|
||||
with_spill_base(|base| {
|
||||
let srv = base.join("srv");
|
||||
fs::create_dir_all(&srv).unwrap();
|
||||
let first = write_spill_file(&srv, "a.bin", 100, 100);
|
||||
let second = write_spill_file(&srv, "b.bin", 100, 200);
|
||||
|
||||
enforce_spill_bound(base, 1000, &second);
|
||||
|
||||
assert!(first.exists());
|
||||
assert!(second.exists());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_tolerates_already_removed_entries() {
|
||||
with_spill_base(|base| {
|
||||
let srv = base.join("srv");
|
||||
fs::create_dir_all(&srv).unwrap();
|
||||
let real = write_spill_file(&srv, "real.bin", 100, 200);
|
||||
let entries = vec![
|
||||
SpillEntry {
|
||||
path: srv.join("ghost.bin"),
|
||||
size: 100,
|
||||
modified: SystemTime::UNIX_EPOCH + Duration::from_secs(100),
|
||||
},
|
||||
SpillEntry {
|
||||
path: real.clone(),
|
||||
size: 100,
|
||||
modified: SystemTime::UNIX_EPOCH + Duration::from_secs(200),
|
||||
},
|
||||
];
|
||||
|
||||
evict_oldest(entries, 50, &base.join("untouched"));
|
||||
|
||||
assert!(!real.exists());
|
||||
});
|
||||
}
|
||||
}
|
||||
+26
-14
@@ -1869,24 +1869,36 @@ fn select_embedding_model(models: &[&Model]) -> Result<String> {
|
||||
}
|
||||
|
||||
pub(crate) fn select_rag_driver() -> Result<String> {
|
||||
let options = vec![
|
||||
"yaml — portable, in-memory HNSW; usable from several Coyote processes at once (default)",
|
||||
"duckdb — persistent on-disk store; vectors and content survive restarts; HNSW approximate search.",
|
||||
];
|
||||
let sel = Select::new("RAG storage driver:", options)
|
||||
.with_starting_cursor(0)
|
||||
.prompt()?;
|
||||
if sel.starts_with("duckdb") {
|
||||
#[cfg(target_env = "musl")]
|
||||
{
|
||||
println!(
|
||||
"Note: several Coyote processes can query a duckdb RAG at the same time, \
|
||||
but while one process is ingesting or rebuilding it the others cannot \
|
||||
read it until that finishes. Changing its driver later means deleting \
|
||||
and recreating the RAG."
|
||||
"Note: the duckdb RAG driver is only available in GNU builds of coyote \
|
||||
(musl builds are statically linked and cannot load DuckDB extensions). \
|
||||
Using the yaml driver."
|
||||
);
|
||||
Ok("duckdb".to_string())
|
||||
} else {
|
||||
Ok("yaml".to_string())
|
||||
}
|
||||
#[cfg(not(target_env = "musl"))]
|
||||
{
|
||||
let options = vec![
|
||||
"yaml — portable, in-memory HNSW; usable from several Coyote processes at once (default)",
|
||||
"duckdb — persistent on-disk store; vectors and content survive restarts; HNSW approximate search.",
|
||||
];
|
||||
let sel = Select::new("RAG storage driver:", options)
|
||||
.with_starting_cursor(0)
|
||||
.prompt()?;
|
||||
if sel.starts_with("duckdb") {
|
||||
println!(
|
||||
"Note: several Coyote processes can query a duckdb RAG at the same time, \
|
||||
but while one process is ingesting or rebuilding it the others cannot \
|
||||
read it until that finishes. Changing its driver later means deleting \
|
||||
and recreating the RAG."
|
||||
);
|
||||
Ok("duckdb".to_string())
|
||||
} else {
|
||||
Ok("yaml".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EXTRACTOR_SKIP: &str = "Skip";
|
||||
|
||||
@@ -85,6 +85,21 @@ pub struct DuckDbProvider {
|
||||
}
|
||||
|
||||
impl DuckDbProvider {
|
||||
/// Fail fast on musl builds, before any filesystem or DuckDB work. Coyote's musl
|
||||
/// builds are statically linked, and a statically linked binary cannot dlopen the
|
||||
/// `vss`/`fts` extension shared objects, so this driver can never work there.
|
||||
fn reject_musl() -> Result<()> {
|
||||
#[cfg(target_env = "musl")]
|
||||
bail!(
|
||||
"The duckdb RAG driver is unavailable in this build of coyote: musl builds \
|
||||
are statically linked, and a statically linked binary cannot load DuckDB \
|
||||
extensions like `vss`/`fts` (dlopen is unsupported). Use the `qdrant` RAG \
|
||||
driver instead, or a gnu-libc build of coyote."
|
||||
);
|
||||
#[cfg(not(target_env = "musl"))]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open (or create) the DuckDB file. `dim` is the embedding vector dimension,
|
||||
/// supplied by the caller who knows the model.
|
||||
///
|
||||
@@ -95,6 +110,7 @@ impl DuckDbProvider {
|
||||
/// created or initialized here, or lazily through `ensure_writable` on the rebuild
|
||||
/// path.
|
||||
pub fn open(db_path: &Path, dim: usize) -> Result<Self> {
|
||||
Self::reject_musl()?;
|
||||
let (conn, writable) = Self::open_for_workload(db_path, dim)?;
|
||||
// A reopened file may already carry a live FTS index from a previous session,
|
||||
// in which case keyword search works immediately.
|
||||
@@ -173,6 +189,7 @@ impl DuckDbProvider {
|
||||
}
|
||||
|
||||
pub fn introspect_dim(db_path: &Path) -> Result<Option<usize>> {
|
||||
Self::reject_musl()?;
|
||||
if !db_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -819,7 +836,9 @@ impl RagProvider for DuckDbProvider {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// Not compiled for musl: `open` bails there (statically linked binaries cannot dlopen
|
||||
// the extension shared objects), so every test in this module would fail at setup.
|
||||
#[cfg(all(test, not(target_env = "musl")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::rag::provider::RagProvider;
|
||||
|
||||
+293
-1
@@ -1,11 +1,17 @@
|
||||
use super::{REPL_COMMANDS, ReplCommand};
|
||||
|
||||
use crate::{config::RequestContext, utils::fuzzy_filter};
|
||||
use crate::config::{McpPromptCompletion, RequestContext, sanitize_display_text};
|
||||
use crate::mcp::ConnectedServer;
|
||||
use crate::utils::fuzzy_filter;
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use reedline::{Completer, Span, Suggestion};
|
||||
use rmcp::model::Prompt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const PROMPT_COMPLETION_RPC_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
impl Completer for ReplCompleter {
|
||||
fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
|
||||
@@ -29,6 +35,22 @@ impl Completer for ReplCompleter {
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
if cmd == ".prompt" && parts_len > 1 {
|
||||
let span = Span::new(parts[parts_len - 1].1, pos);
|
||||
let args: Vec<&str> = parts.iter().skip(1).map(|(v, _)| *v).collect();
|
||||
let filter = args.last().copied().unwrap_or_default().to_string();
|
||||
let stage = {
|
||||
let ctx = self.ctx.read();
|
||||
ctx.mcp_prompt_completion(&args)
|
||||
};
|
||||
return complete_prompt_stage(stage, &filter, PROMPT_COMPLETION_RPC_TIMEOUT)
|
||||
.iter()
|
||||
.map(|(value, description)| {
|
||||
create_suggestion(value, description.as_deref().unwrap_or_default(), span)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let ctx = self.ctx.read();
|
||||
let state = ctx.state();
|
||||
let model_has_reasoning = !ctx.current_model().reasoning_levels().is_empty();
|
||||
@@ -141,6 +163,71 @@ fn create_suggestion(value: &str, description: &str, span: Span) -> Suggestion {
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_prompt_stage(
|
||||
stage: McpPromptCompletion,
|
||||
filter: &str,
|
||||
rpc_timeout: Duration,
|
||||
) -> Vec<(String, Option<String>)> {
|
||||
let values = match stage {
|
||||
McpPromptCompletion::Ready(values) => values,
|
||||
McpPromptCompletion::PromptNames { server } => list_prompts_blocking(server, rpc_timeout)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|prompt| {
|
||||
(
|
||||
sanitize_display_text(&prompt.name),
|
||||
prompt
|
||||
.description
|
||||
.map(|description| sanitize_display_text(&description)),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
McpPromptCompletion::ArgumentKeys {
|
||||
server,
|
||||
prompt,
|
||||
typed_keys,
|
||||
} => list_prompts_blocking(server, rpc_timeout)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.name == prompt)
|
||||
.and_then(|candidate| candidate.arguments)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|arg| !typed_keys.contains(&arg.name))
|
||||
.map(|arg| {
|
||||
let description = arg
|
||||
.description
|
||||
.map(|description| sanitize_display_text(&description));
|
||||
let description = match (arg.required == Some(true), description) {
|
||||
(true, Some(description)) => Some(format!("{description} (required)")),
|
||||
(true, None) => Some("(required)".to_string()),
|
||||
(false, description) => description,
|
||||
};
|
||||
(
|
||||
format!("{}=", sanitize_display_text(&arg.name)),
|
||||
description,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
fuzzy_filter(values, |(value, _)| value.as_str(), filter)
|
||||
}
|
||||
|
||||
fn list_prompts_blocking(
|
||||
server: Arc<ConnectedServer>,
|
||||
rpc_timeout: Duration,
|
||||
) -> Option<Vec<Prompt>> {
|
||||
let fut = async move { tokio::time::timeout(rpc_timeout, server.list_all_prompts()).await };
|
||||
// block_in_place is only sound because the REPL's read_line runs inside the
|
||||
// main-thread block_on of the multi-thread runtime.
|
||||
let result = match tokio::runtime::Handle::try_current().ok() {
|
||||
Some(handle) => tokio::task::block_in_place(|| handle.block_on(fut)),
|
||||
None => tokio::runtime::Runtime::new().ok()?.block_on(fut),
|
||||
};
|
||||
|
||||
result.ok()?.ok()
|
||||
}
|
||||
|
||||
fn split_line(line: &str) -> Vec<(&str, usize)> {
|
||||
let mut parts = vec![];
|
||||
let mut part_start = None;
|
||||
@@ -178,3 +265,208 @@ fn test_split_line() {
|
||||
vec![(".set", 0), ("highlight", 5), ("t", 15)],
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prompt_completion_tests {
|
||||
use super::*;
|
||||
use crate::config::test_fixtures::{FixtureServer, fixture_runtime};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
fn prompts_fixture() -> FixtureServer {
|
||||
FixtureServer {
|
||||
prompts_capability: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stage_two_lists_prompt_names_with_descriptions() {
|
||||
let (runtime, _server) = fixture_runtime(prompts_fixture()).await;
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::PromptNames { server },
|
||||
"",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
values,
|
||||
vec![(
|
||||
"summarize".to_string(),
|
||||
Some("Summarize a document".to_string())
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stage_three_suggests_argument_keys_with_required_marker() {
|
||||
let (runtime, _server) = fixture_runtime(prompts_fixture()).await;
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::ArgumentKeys {
|
||||
server,
|
||||
prompt: "summarize".to_string(),
|
||||
typed_keys: vec![],
|
||||
},
|
||||
"",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
values,
|
||||
vec![
|
||||
(
|
||||
"path=".to_string(),
|
||||
Some("Document path (required)".to_string())
|
||||
),
|
||||
("style=".to_string(), None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stage_three_excludes_typed_keys_and_fuzzy_filters() {
|
||||
let (runtime, _server) = fixture_runtime(prompts_fixture()).await;
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::ArgumentKeys {
|
||||
server: Arc::clone(&server),
|
||||
prompt: "summarize".to_string(),
|
||||
typed_keys: vec!["path".to_string()],
|
||||
},
|
||||
"",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
assert_eq!(values, vec![("style=".to_string(), None)]);
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::ArgumentKeys {
|
||||
server,
|
||||
prompt: "summarize".to_string(),
|
||||
typed_keys: vec![],
|
||||
},
|
||||
"sty",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
assert_eq!(values, vec![("style=".to_string(), None)]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stage_three_unknown_prompt_is_empty() {
|
||||
let (runtime, _server) = fixture_runtime(prompts_fixture()).await;
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::ArgumentKeys {
|
||||
server,
|
||||
prompt: "ghost".to_string(),
|
||||
typed_keys: vec![],
|
||||
},
|
||||
"",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
assert!(values.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn hostile_prompt_strings_are_sanitized_in_suggestions() {
|
||||
let fixture = FixtureServer {
|
||||
hostile_prompt: true,
|
||||
..prompts_fixture()
|
||||
};
|
||||
let (runtime, _server) = fixture_runtime(fixture).await;
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::PromptNames {
|
||||
server: Arc::clone(&server),
|
||||
},
|
||||
"evil",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
assert_eq!(
|
||||
values,
|
||||
vec![(
|
||||
"summarize-evil".to_string(),
|
||||
Some("Runs hostile text".to_string())
|
||||
)]
|
||||
);
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::ArgumentKeys {
|
||||
server,
|
||||
prompt: "sum\u{1b}[31mmarize-evil".to_string(),
|
||||
typed_keys: vec![],
|
||||
},
|
||||
"",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
assert_eq!(
|
||||
values,
|
||||
vec![("path=".to_string(), Some("Doc path (required)".to_string()))]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn slow_listing_times_out_to_empty() {
|
||||
let fixture = FixtureServer {
|
||||
prompt_delay: Some(Duration::from_millis(200)),
|
||||
..prompts_fixture()
|
||||
};
|
||||
let (runtime, _server) = fixture_runtime(fixture).await;
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::PromptNames { server },
|
||||
"",
|
||||
Duration::from_millis(20),
|
||||
);
|
||||
|
||||
assert!(values.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn failed_listing_is_swallowed_without_retry() {
|
||||
let fixture = FixtureServer {
|
||||
fail_prompt_listings: true,
|
||||
..prompts_fixture()
|
||||
};
|
||||
let list_prompts_calls = Arc::clone(&fixture.list_prompts_calls);
|
||||
let (runtime, _server) = fixture_runtime(fixture).await;
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::PromptNames { server },
|
||||
"",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
assert!(values.is_empty());
|
||||
assert_eq!(list_prompts_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_without_ambient_runtime_uses_fallback_runtime() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let (runtime, _server) = rt.block_on(fixture_runtime(prompts_fixture()));
|
||||
let server = runtime.get("fixture").cloned().unwrap();
|
||||
|
||||
let values = complete_prompt_stage(
|
||||
McpPromptCompletion::PromptNames { server },
|
||||
"",
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
values,
|
||||
vec![(
|
||||
"summarize".to_string(),
|
||||
Some("Summarize a document".to_string())
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+158
-21
@@ -13,10 +13,10 @@ use crate::client::{
|
||||
};
|
||||
use crate::config::{
|
||||
AgentVariables, AppConfig, AssertState, Input, LastMessage, MacroState, RequestContext,
|
||||
StateFlags, macro_execute,
|
||||
StateFlags, flatten_prompt_messages, macro_execute, resolve_prompt_args, sanitize_display_text,
|
||||
};
|
||||
use crate::config::{AssetCategory, paths};
|
||||
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
|
||||
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
||||
use crate::render::render_error;
|
||||
use crate::utils::{
|
||||
AbortSignal, SHELL, abortable_run_with_spinner, create_abort_signal, dimmed_text,
|
||||
@@ -29,6 +29,7 @@ use anyhow::{Context, Result, bail};
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use fancy_regex::Regex;
|
||||
use indoc::indoc;
|
||||
use inquire::Text;
|
||||
use log::warn;
|
||||
use parking_lot::RwLock;
|
||||
use reedline::CursorConfig;
|
||||
@@ -38,6 +39,7 @@ use reedline::{
|
||||
default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings,
|
||||
};
|
||||
use reedline::{MenuBuilder, Signal};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::{env, process, sync::Arc};
|
||||
use tokio::task;
|
||||
@@ -53,7 +55,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
||||
4. Continue with the next pending item now. Call tools immediately."
|
||||
};
|
||||
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 61]> = LazyLock::new(|| {
|
||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 63]> = LazyLock::new(|| {
|
||||
[
|
||||
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
||||
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
||||
@@ -62,6 +64,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 61]> = LazyLock::new(|| {
|
||||
"Show the list of enabled tools to be passed to the LLM",
|
||||
AssertState::True(StateFlags::FUNCTION_CALLING),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".info mcp-server",
|
||||
"Show an MCP server's tool filters and effective catalog",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".authenticate",
|
||||
"Authenticate the current model client via OAuth (if configured)",
|
||||
@@ -105,6 +112,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 61]> = LazyLock::new(|| {
|
||||
ReplCommand::new(".model", "Switch LLM model", AssertState::pass()),
|
||||
ReplCommand::new(
|
||||
".prompt",
|
||||
"Invoke an MCP prompt and submit the result as chat input",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
".temp-role",
|
||||
"Set a temporary role using a prompt",
|
||||
AssertState::False(StateFlags::SESSION | StateFlags::AGENT),
|
||||
),
|
||||
@@ -307,7 +319,7 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 61]> = LazyLock::new(|| {
|
||||
),
|
||||
ReplCommand::new(
|
||||
".list",
|
||||
"List roles, sessions, agents, RAGs, macros, skills, tools, MCP servers, or bundles",
|
||||
"List roles, sessions, agents, RAGs, macros, skills, prompts, tools, MCP servers, or bundles",
|
||||
AssertState::pass(),
|
||||
),
|
||||
ReplCommand::new(
|
||||
@@ -595,7 +607,7 @@ pub async fn run_repl_command(
|
||||
abort_signal: AbortSignal,
|
||||
mut line: &str,
|
||||
) -> Result<bool> {
|
||||
ctx.pending_agents_guardrail_count = 0;
|
||||
ctx.pending_tasks_guardrail_count = 0;
|
||||
if let Ok(Some(captures)) = MULTILINE_RE.captures(line)
|
||||
&& let Some(text_match) = captures.get(1)
|
||||
{
|
||||
@@ -632,6 +644,16 @@ pub async fn run_repl_command(
|
||||
let info = ctx.todo_info()?;
|
||||
print!("{info}");
|
||||
}
|
||||
Some(arg) if arg.starts_with("mcp-server") => {
|
||||
let mut parts = arg.splitn(2, char::is_whitespace);
|
||||
parts.next();
|
||||
let name = parts.next().map(str::trim).unwrap_or("");
|
||||
if name.is_empty() {
|
||||
bail!("Usage: .info mcp-server <server>");
|
||||
}
|
||||
let info = ctx.mcp_server_info(name).await?;
|
||||
print!("{info}");
|
||||
}
|
||||
Some(_) => unknown_command()?,
|
||||
None => {
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
@@ -774,12 +796,47 @@ pub async fn run_repl_command(
|
||||
.tool disable <name> # Disable a single tool in the current context"#
|
||||
),
|
||||
},
|
||||
".prompt" => match args {
|
||||
".prompt" => {
|
||||
let (words, _) = split_args_text(args.unwrap_or_default(), cfg!(windows));
|
||||
match words.as_slice() {
|
||||
[server, name, rest @ ..] => {
|
||||
let provided = parse_prompt_call_args(rest)?;
|
||||
let prompts = ctx.tool_scope.mcp_runtime.list_prompts(server).await?;
|
||||
let declared = prompts
|
||||
.into_iter()
|
||||
.find(|prompt| prompt.name == *name)
|
||||
.with_context(|| {
|
||||
format!("Prompt '{name}' not found on MCP server '{server}'")
|
||||
})?
|
||||
.arguments
|
||||
.unwrap_or_default();
|
||||
let (mut arguments, missing) = resolve_prompt_args(&declared, provided);
|
||||
for key in missing {
|
||||
let value = Text::new(&prompt_arg_inquire_label(server, name, &key))
|
||||
.prompt()
|
||||
.with_context(|| {
|
||||
format!("Failed to read prompt argument '{key}'")
|
||||
})?;
|
||||
arguments.insert(key, value);
|
||||
}
|
||||
let result = ctx
|
||||
.tool_scope
|
||||
.mcp_runtime
|
||||
.prompt(server, name, arguments)
|
||||
.await?;
|
||||
let flattened = flatten_prompt_messages(&result.messages);
|
||||
let input = Input::from_str(ctx, &flattened, None)?;
|
||||
ask(ctx, abort_signal.clone(), input, true).await?;
|
||||
}
|
||||
_ => println!("Usage: .prompt <server> <name> [key=value ...]"),
|
||||
}
|
||||
}
|
||||
".temp-role" => match args {
|
||||
Some(text) => {
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
ctx.use_prompt(app.as_ref(), text)?;
|
||||
ctx.use_temp_role(app.as_ref(), text)?;
|
||||
}
|
||||
None => println!("Usage: .prompt <text>..."),
|
||||
None => println!("Usage: .temp-role <text>..."),
|
||||
},
|
||||
".role" => match args {
|
||||
Some(args) => match args.split_once(['\n', ' ']) {
|
||||
@@ -1149,12 +1206,7 @@ pub async fn run_repl_command(
|
||||
ask(ctx, abort_signal.clone(), input, true).await?;
|
||||
}
|
||||
".recover" => {
|
||||
let has_recoverable = ctx
|
||||
.last_message
|
||||
.as_ref()
|
||||
.map(|v| v.continuous && v.input.with_session())
|
||||
.unwrap_or(false);
|
||||
if !has_recoverable {
|
||||
if !ctx.has_recoverable_interruption() {
|
||||
bail!("Unable to recover: no interrupted session response to recover from");
|
||||
}
|
||||
let recovery_text = args
|
||||
@@ -1207,13 +1259,16 @@ pub async fn run_repl_command(
|
||||
println!("Usage: .uninstall <bundle-name> [--yes] (see `.uninstall --help`)")
|
||||
}
|
||||
},
|
||||
".list" => match args {
|
||||
".list" => match args.map(str::trim) {
|
||||
Some("prompts") => {
|
||||
ctx.list_mcp_prompts().await?;
|
||||
}
|
||||
Some(args) => {
|
||||
ctx.list_assets(args.trim())?;
|
||||
ctx.list_assets(args)?;
|
||||
}
|
||||
_ => {
|
||||
println!(
|
||||
"Usage: .list <roles|sessions|agents|rags|macros|skills|tools|mcp-servers|bundles>"
|
||||
"Usage: .list <roles|sessions|agents|rags|macros|skills|prompts|tools|mcp-servers|bundles>"
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -1430,7 +1485,7 @@ async fn ask(
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
match check_pending_agents_guardrail(ctx) {
|
||||
match check_pending_tasks_guardrail(ctx) {
|
||||
GuardrailAction::Inject(prompt) => {
|
||||
let guardrail_input = Input::from_str(ctx, &prompt, None)?;
|
||||
return ask(ctx, abort_signal, guardrail_input, false).await;
|
||||
@@ -1737,6 +1792,37 @@ fn split_first_arg(args: Option<&str>) -> Option<(&str, Option<&str>)> {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_prompt_call_args(words: &[String]) -> Result<HashMap<String, String>> {
|
||||
let mut args = HashMap::new();
|
||||
for word in words {
|
||||
let Some((key, value)) = word.split_once('=') else {
|
||||
bail!("Invalid prompt argument '{word}': arguments must be key=value pairs");
|
||||
};
|
||||
args.insert(key.to_string(), unquote_prompt_value(value).to_string());
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn unquote_prompt_value(value: &str) -> &str {
|
||||
let quoted = value.len() >= 2
|
||||
&& ((value.starts_with('"') && value.ends_with('"'))
|
||||
|| (value.starts_with('\'') && value.ends_with('\'')));
|
||||
if quoted {
|
||||
&value[1..value.len() - 1]
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_arg_inquire_label(server: &str, prompt: &str, arg: &str) -> String {
|
||||
format!(
|
||||
"Prompt '{}' on '{}' requires '{}':",
|
||||
sanitize_display_text(prompt),
|
||||
sanitize_display_text(server),
|
||||
sanitize_display_text(arg)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn split_args_text(line: &str, is_win: bool) -> (Vec<String>, &str) {
|
||||
let mut words = Vec::new();
|
||||
let mut word = String::new();
|
||||
@@ -1888,8 +1974,47 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_commands_has_61_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 61);
|
||||
fn repl_commands_has_63_entries() {
|
||||
assert_eq!(REPL_COMMANDS.len(), 63);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_prompt_call_args_splits_on_first_equals_and_unquotes() {
|
||||
let words = vec![
|
||||
"path=notes.txt".to_string(),
|
||||
r#"style="a b""#.to_string(),
|
||||
"expr=a=b".to_string(),
|
||||
];
|
||||
|
||||
let args = parse_prompt_call_args(&words).unwrap();
|
||||
|
||||
assert_eq!(args["path"], "notes.txt");
|
||||
assert_eq!(args["style"], "a b");
|
||||
assert_eq!(args["expr"], "a=b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_prompt_call_args_rejects_words_without_equals() {
|
||||
let err = parse_prompt_call_args(&["positional".to_string()])
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
"Invalid prompt argument 'positional': arguments must be key=value pairs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_arg_inquire_label_sanitizes_all_components() {
|
||||
assert_eq!(
|
||||
prompt_arg_inquire_label("srv", "summarize", "path"),
|
||||
"Prompt 'summarize' on 'srv' requires 'path':"
|
||||
);
|
||||
assert_eq!(
|
||||
prompt_arg_inquire_label("s\u{1b}[31mrv", "sum\u{1b}]0;x\u{7}marize", "pa\u{7}th"),
|
||||
"Prompt 'summarize' on 'srv' requires 'pa th':"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2105,10 +2230,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_commands_prompt_blocked_in_session_or_agent() {
|
||||
fn repl_commands_prompt_always_available() {
|
||||
let cmd = REPL_COMMANDS.iter().find(|c| c.name == ".prompt").unwrap();
|
||||
assert!(cmd.is_valid(StateFlags::empty()));
|
||||
assert!(cmd.is_valid(StateFlags::ROLE));
|
||||
assert!(cmd.is_valid(StateFlags::SESSION));
|
||||
assert!(cmd.is_valid(StateFlags::AGENT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_commands_temp_role_blocked_in_session_or_agent() {
|
||||
let cmd = REPL_COMMANDS
|
||||
.iter()
|
||||
.find(|c| c.name == ".temp-role")
|
||||
.unwrap();
|
||||
assert!(cmd.is_valid(StateFlags::empty()));
|
||||
assert!(cmd.is_valid(StateFlags::ROLE));
|
||||
assert!(!cmd.is_valid(StateFlags::SESSION));
|
||||
assert!(!cmd.is_valid(StateFlags::AGENT));
|
||||
}
|
||||
|
||||
+363
-29
@@ -1,17 +1,21 @@
|
||||
pub mod escalation;
|
||||
pub mod mailbox;
|
||||
pub mod notification;
|
||||
pub mod taskqueue;
|
||||
|
||||
use crate::function::jobs::RingBuf;
|
||||
use crate::utils::AbortSignal;
|
||||
use fmt::{Debug, Formatter};
|
||||
use mailbox::Inbox;
|
||||
use parking_lot::RwLock;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use taskqueue::TaskQueue;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -37,11 +41,85 @@ pub struct AgentHandle {
|
||||
pub child_supervisor: Option<Arc<RwLock<Supervisor>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JobStatus {
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
pub struct JobState {
|
||||
pub status: JobStatus,
|
||||
pub pgid: Option<i32>,
|
||||
}
|
||||
|
||||
pub struct JobResult {
|
||||
pub output: Value,
|
||||
pub exit_code: Option<i32>,
|
||||
pub output_bytes_captured: u64,
|
||||
}
|
||||
|
||||
pub struct JobHandle {
|
||||
pub id: String,
|
||||
pub tool: String,
|
||||
pub started_at: Instant,
|
||||
pub join_handle: JoinHandle<Result<JobResult>>,
|
||||
pub abort_signal: AbortSignal,
|
||||
pub state: Arc<Mutex<JobState>>,
|
||||
pub output_buf: Arc<Mutex<RingBuf>>,
|
||||
pub no_change_checks: u32,
|
||||
pub last_check_state: Option<(JobStatus, u64)>,
|
||||
}
|
||||
|
||||
impl JobHandle {
|
||||
// pgid == child pid under process_group(0); after wait() reaps the child
|
||||
// the pid can be recycled, so never kill unless pgid is still set.
|
||||
fn kill_process_group(&self) {
|
||||
#[cfg(unix)]
|
||||
if let Some(pgid) = self.state.lock().pgid {
|
||||
unsafe {
|
||||
libc::killpg(pgid, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for JobHandle {
|
||||
fn drop(&mut self) {
|
||||
self.kill_process_group();
|
||||
self.join_handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TaskHandle {
|
||||
Agent(AgentHandle),
|
||||
Job(JobHandle),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TaskKind {
|
||||
Agent,
|
||||
Job,
|
||||
}
|
||||
|
||||
impl From<AgentHandle> for TaskHandle {
|
||||
fn from(handle: AgentHandle) -> Self {
|
||||
Self::Agent(handle)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<JobHandle> for TaskHandle {
|
||||
fn from(handle: JobHandle) -> Self {
|
||||
Self::Job(handle)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Supervisor {
|
||||
handles: HashMap<String, AgentHandle>,
|
||||
handles: HashMap<String, TaskHandle>,
|
||||
task_queue: TaskQueue,
|
||||
max_concurrent: usize,
|
||||
max_depth: usize,
|
||||
max_concurrent_jobs: usize,
|
||||
}
|
||||
|
||||
impl Supervisor {
|
||||
@@ -51,17 +129,64 @@ impl Supervisor {
|
||||
task_queue: TaskQueue::new(),
|
||||
max_concurrent,
|
||||
max_depth,
|
||||
max_concurrent_jobs: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_max_concurrent_jobs(mut self, max_concurrent_jobs: usize) -> Self {
|
||||
self.max_concurrent_jobs = max_concurrent_jobs;
|
||||
self
|
||||
}
|
||||
|
||||
fn agent(&self, id: &str) -> Option<&AgentHandle> {
|
||||
match self.handles.get(id) {
|
||||
Some(TaskHandle::Agent(handle)) => Some(handle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn agents(&self) -> impl Iterator<Item = &AgentHandle> {
|
||||
self.handles.values().filter_map(|handle| match handle {
|
||||
TaskHandle::Agent(handle) => Some(handle),
|
||||
TaskHandle::Job(_) => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn job(&self, id: &str) -> Option<&JobHandle> {
|
||||
match self.handles.get(id) {
|
||||
Some(TaskHandle::Job(handle)) => Some(handle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn job_mut(&mut self, id: &str) -> Option<&mut JobHandle> {
|
||||
match self.handles.get_mut(id) {
|
||||
Some(TaskHandle::Job(handle)) => Some(handle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jobs(&self) -> impl Iterator<Item = &JobHandle> {
|
||||
self.handles.values().filter_map(|handle| match handle {
|
||||
TaskHandle::Job(handle) => Some(handle),
|
||||
TaskHandle::Agent(_) => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.handles.len()
|
||||
self.agents().count()
|
||||
}
|
||||
|
||||
pub fn effective_active_count(&self) -> usize {
|
||||
self.agents()
|
||||
.filter(|h| !h.join_handle.is_finished())
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn active_job_count(&self) -> usize {
|
||||
self.handles
|
||||
.values()
|
||||
.filter(|h| !h.join_handle.is_finished())
|
||||
.filter(|h| matches!(h, TaskHandle::Job(job) if !job.join_handle.is_finished()))
|
||||
.count()
|
||||
}
|
||||
|
||||
@@ -73,6 +198,10 @@ impl Supervisor {
|
||||
self.max_depth
|
||||
}
|
||||
|
||||
pub fn max_concurrent_jobs(&self) -> usize {
|
||||
self.max_concurrent_jobs
|
||||
}
|
||||
|
||||
pub fn task_queue(&self) -> &TaskQueue {
|
||||
&self.task_queue
|
||||
}
|
||||
@@ -81,59 +210,128 @@ impl Supervisor {
|
||||
&mut self.task_queue
|
||||
}
|
||||
|
||||
pub fn register(&mut self, handle: AgentHandle) -> Result<()> {
|
||||
if self.effective_active_count() >= self.max_concurrent {
|
||||
bail!(
|
||||
"Cannot spawn agent: at capacity ({}/{})",
|
||||
self.effective_active_count(),
|
||||
self.max_concurrent
|
||||
);
|
||||
pub fn register(&mut self, handle: impl Into<TaskHandle>) -> Result<()> {
|
||||
match handle.into() {
|
||||
TaskHandle::Agent(handle) => {
|
||||
if self.effective_active_count() >= self.max_concurrent {
|
||||
bail!(
|
||||
"Cannot spawn agent: at capacity ({}/{})",
|
||||
self.effective_active_count(),
|
||||
self.max_concurrent
|
||||
);
|
||||
}
|
||||
if handle.depth > self.max_depth {
|
||||
bail!(
|
||||
"Cannot spawn agent: max depth exceeded ({}/{})",
|
||||
handle.depth,
|
||||
self.max_depth
|
||||
);
|
||||
}
|
||||
self.handles
|
||||
.insert(handle.id.clone(), TaskHandle::Agent(handle));
|
||||
}
|
||||
TaskHandle::Job(handle) => {
|
||||
if self.active_job_count() >= self.max_concurrent_jobs {
|
||||
bail!(
|
||||
"Cannot start job: at capacity ({}/{})",
|
||||
self.active_job_count(),
|
||||
self.max_concurrent_jobs
|
||||
);
|
||||
}
|
||||
self.handles
|
||||
.insert(handle.id.clone(), TaskHandle::Job(handle));
|
||||
}
|
||||
}
|
||||
if handle.depth > self.max_depth {
|
||||
bail!(
|
||||
"Cannot spawn agent: max depth exceeded ({}/{})",
|
||||
handle.depth,
|
||||
self.max_depth
|
||||
);
|
||||
}
|
||||
self.handles.insert(handle.id.clone(), handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_finished(&self, id: &str) -> Option<bool> {
|
||||
self.handles.get(id).map(|h| h.join_handle.is_finished())
|
||||
self.agent(id).map(|h| h.join_handle.is_finished())
|
||||
}
|
||||
|
||||
pub fn take(&mut self, id: &str) -> Option<AgentHandle> {
|
||||
self.handles.remove(id)
|
||||
self.agent(id)?;
|
||||
match self.handles.remove(id) {
|
||||
Some(TaskHandle::Agent(handle)) => Some(handle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_job(&mut self, id: &str) -> Option<JobHandle> {
|
||||
if !self.has_job(id) {
|
||||
return None;
|
||||
}
|
||||
match self.handles.remove(id) {
|
||||
Some(TaskHandle::Job(handle)) => Some(handle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_job(&self, id: &str) -> bool {
|
||||
matches!(self.handles.get(id), Some(TaskHandle::Job(_)))
|
||||
}
|
||||
|
||||
pub fn has_agent(&self, id: &str) -> bool {
|
||||
self.agent(id).is_some()
|
||||
}
|
||||
|
||||
pub fn inbox(&self, id: &str) -> Option<&Arc<Inbox>> {
|
||||
self.handles.get(id).map(|h| &h.inbox)
|
||||
self.agent(id).map(|h| &h.inbox)
|
||||
}
|
||||
|
||||
pub fn abort_signal_for(&self, id: &str) -> Option<AbortSignal> {
|
||||
self.handles.get(id).map(|h| h.abort_signal.clone())
|
||||
self.agent(id).map(|h| h.abort_signal.clone())
|
||||
}
|
||||
|
||||
pub fn list_agents(&self) -> Vec<(&str, &str)> {
|
||||
self.agents()
|
||||
.map(|h| (h.id.as_str(), h.agent_name.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn list_tasks(&self) -> Vec<(&str, TaskKind, bool)> {
|
||||
self.handles
|
||||
.values()
|
||||
.map(|h| (h.id.as_str(), h.agent_name.as_str()))
|
||||
.map(|handle| match handle {
|
||||
TaskHandle::Agent(agent) => (
|
||||
agent.id.as_str(),
|
||||
TaskKind::Agent,
|
||||
agent.join_handle.is_finished(),
|
||||
),
|
||||
TaskHandle::Job(job) => (
|
||||
job.id.as_str(),
|
||||
TaskKind::Job,
|
||||
job.join_handle.is_finished(),
|
||||
),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn cancel_all(&self) {
|
||||
for handle in self.handles.values() {
|
||||
handle.abort_signal.set_ctrlc();
|
||||
match handle {
|
||||
TaskHandle::Agent(agent) => agent.abort_signal.set_ctrlc(),
|
||||
TaskHandle::Job(job) => {
|
||||
job.abort_signal.set_ctrlc();
|
||||
job.kill_process_group();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_recursive(&self) {
|
||||
for handle in self.handles.values() {
|
||||
handle.abort_signal.set_ctrlc();
|
||||
if let Some(child_sup) = handle.child_supervisor.as_ref() {
|
||||
child_sup.read().cancel_recursive();
|
||||
match handle {
|
||||
TaskHandle::Agent(agent) => {
|
||||
agent.abort_signal.set_ctrlc();
|
||||
if let Some(child_sup) = agent.child_supervisor.as_ref() {
|
||||
child_sup.read().cancel_recursive();
|
||||
}
|
||||
}
|
||||
TaskHandle::Job(job) => {
|
||||
job.abort_signal.set_ctrlc();
|
||||
job.kill_process_group();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,7 +340,7 @@ impl Supervisor {
|
||||
impl Debug for Supervisor {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Supervisor")
|
||||
.field("active_agents", &self.handles.len())
|
||||
.field("active_agents", &self.active_count())
|
||||
.field("max_concurrent", &self.max_concurrent)
|
||||
.field("max_depth", &self.max_depth)
|
||||
.finish()
|
||||
@@ -154,6 +352,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::utils::create_abort_signal;
|
||||
use anyhow::Error;
|
||||
use std::mem;
|
||||
use tokio::runtime::Builder;
|
||||
|
||||
fn make_handle(id: &str, agent_name: &str, depth: usize) -> AgentHandle {
|
||||
@@ -177,6 +376,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_job(id: &str, abort_signal: AbortSignal) -> JobHandle {
|
||||
// Keep the runtime alive so the spawned task is never polled and the
|
||||
// job counts as running for capacity checks.
|
||||
let rt = Builder::new_current_thread().enable_all().build().unwrap();
|
||||
let join_handle = rt.spawn(async {
|
||||
Ok(JobResult {
|
||||
output: Value::Null,
|
||||
exit_code: Some(0),
|
||||
output_bytes_captured: 0,
|
||||
})
|
||||
});
|
||||
mem::forget(rt);
|
||||
JobHandle {
|
||||
id: id.to_string(),
|
||||
tool: "execute_command".to_string(),
|
||||
started_at: Instant::now(),
|
||||
join_handle,
|
||||
abort_signal,
|
||||
state: Arc::new(Mutex::new(JobState {
|
||||
status: JobStatus::Running,
|
||||
pgid: None,
|
||||
})),
|
||||
output_buf: Arc::new(Mutex::new(RingBuf::default())),
|
||||
no_change_checks: 0,
|
||||
last_check_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supervisor_new_empty() {
|
||||
let sup = Supervisor::new(4, 3);
|
||||
@@ -294,4 +521,111 @@ mod tests {
|
||||
AgentExitStatus::Failed("x".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_recursive_aborts_nested_supervisors() {
|
||||
let child_sig = create_abort_signal();
|
||||
let mut child_handle = make_handle("c1", "worker", 2);
|
||||
child_handle.abort_signal = child_sig.clone();
|
||||
let mut child_sup = Supervisor::new(4, 3);
|
||||
child_sup.register(child_handle).unwrap();
|
||||
|
||||
let parent_sig = create_abort_signal();
|
||||
let mut parent_handle = make_handle("a1", "explore", 1);
|
||||
parent_handle.abort_signal = parent_sig.clone();
|
||||
parent_handle.child_supervisor = Some(Arc::new(RwLock::new(child_sup)));
|
||||
let mut sup = Supervisor::new(4, 3);
|
||||
sup.register(parent_handle).unwrap();
|
||||
|
||||
sup.cancel_recursive();
|
||||
|
||||
assert!(parent_sig.aborted());
|
||||
assert!(child_sig.aborted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_registration_rejects_when_job_capacity_zero() {
|
||||
let mut sup = Supervisor::new(4, 3);
|
||||
|
||||
let result = sup.register(make_job("j1", create_abort_signal()));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("at capacity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_registration_rejects_at_job_capacity() {
|
||||
let mut sup = Supervisor::new(4, 3).with_max_concurrent_jobs(1);
|
||||
sup.register(make_job("j1", create_abort_signal())).unwrap();
|
||||
|
||||
let result = sup.register(make_job("j2", create_abort_signal()));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("at capacity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_capacity_is_independent_of_agent_capacity() {
|
||||
let mut sup = Supervisor::new(1, 3).with_max_concurrent_jobs(1);
|
||||
|
||||
sup.register(make_job("j1", create_abort_signal())).unwrap();
|
||||
sup.register(make_handle("a1", "explore", 1)).unwrap();
|
||||
|
||||
assert_eq!(sup.active_job_count(), 1);
|
||||
assert_eq!(sup.active_count(), 1);
|
||||
assert_eq!(sup.max_concurrent_jobs(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_accessors_ignore_jobs() {
|
||||
let mut sup = Supervisor::new(4, 3).with_max_concurrent_jobs(2);
|
||||
|
||||
sup.register(make_job("j1", create_abort_signal())).unwrap();
|
||||
|
||||
assert_eq!(sup.active_count(), 0);
|
||||
assert_eq!(sup.effective_active_count(), 0);
|
||||
assert!(sup.list_agents().is_empty());
|
||||
assert_eq!(sup.is_finished("j1"), None);
|
||||
assert!(sup.inbox("j1").is_none());
|
||||
assert!(sup.abort_signal_for("j1").is_none());
|
||||
assert!(sup.take("j1").is_none());
|
||||
assert!(sup.has_job("j1"));
|
||||
assert!(!sup.has_agent("j1"));
|
||||
assert_eq!(sup.active_job_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_job_removes_job_but_not_agents() {
|
||||
let mut sup = Supervisor::new(4, 3).with_max_concurrent_jobs(2);
|
||||
|
||||
sup.register(make_job("j1", create_abort_signal())).unwrap();
|
||||
sup.register(make_handle("a1", "explore", 1)).unwrap();
|
||||
|
||||
assert!(sup.take_job("a1").is_none());
|
||||
assert!(sup.has_agent("a1"));
|
||||
assert!(sup.take_job("j1").is_some());
|
||||
assert_eq!(sup.active_job_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_recursive_aborts_jobs() {
|
||||
let sig = create_abort_signal();
|
||||
let mut sup = Supervisor::new(4, 3).with_max_concurrent_jobs(1);
|
||||
sup.register(make_job("j1", sig.clone())).unwrap();
|
||||
|
||||
sup.cancel_recursive();
|
||||
|
||||
assert!(sig.aborted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_all_aborts_jobs() {
|
||||
let sig = create_abort_signal();
|
||||
let mut sup = Supervisor::new(4, 3).with_max_concurrent_jobs(1);
|
||||
sup.register(make_job("j1", sig.clone())).unwrap();
|
||||
|
||||
sup.cancel_all();
|
||||
|
||||
assert!(sig.aborted());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
use fmt::{Debug, Formatter};
|
||||
use serde_json::{Value, json};
|
||||
use std::fmt;
|
||||
|
||||
/// One background-task completion event, delivered to the context that
|
||||
/// started the task by merging a `system_notifications` entry onto the last
|
||||
/// tool result of a batch.
|
||||
#[derive(Clone)]
|
||||
pub struct SystemNotification {
|
||||
pub event: &'static str,
|
||||
pub id: String,
|
||||
pub tool_or_agent: String,
|
||||
pub status: &'static str,
|
||||
pub next_action: String,
|
||||
}
|
||||
|
||||
impl SystemNotification {
|
||||
pub fn to_value(&self) -> Value {
|
||||
json!({
|
||||
"event": self.event,
|
||||
"id": self.id,
|
||||
"tool_or_agent": self.tool_or_agent,
|
||||
"status": self.status,
|
||||
"next_action": self.next_action,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn job_notification(id: &str, tool: &str, success: bool) -> SystemNotification {
|
||||
SystemNotification {
|
||||
event: if success {
|
||||
"job_completed"
|
||||
} else {
|
||||
"job_failed"
|
||||
},
|
||||
id: id.to_string(),
|
||||
tool_or_agent: tool.to_string(),
|
||||
status: if success { "success" } else { "failed" },
|
||||
next_action: format!("job__collect --id {id} for output"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn agent_notification(id: &str, agent_name: &str, success: bool) -> SystemNotification {
|
||||
SystemNotification {
|
||||
event: if success {
|
||||
"agent_completed"
|
||||
} else {
|
||||
"agent_failed"
|
||||
},
|
||||
id: id.to_string(),
|
||||
tool_or_agent: agent_name.to_string(),
|
||||
status: if success { "success" } else { "failed" },
|
||||
next_action: format!("agent__collect --id {id} for output"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Completion events for background work started by ONE context. Unlike the
|
||||
/// escalation queue (shared, root-owned), every context owns a fresh queue:
|
||||
/// a queue shared between parent and child would race their drains and
|
||||
/// deliver one context's events into the other's transcript.
|
||||
pub struct NotificationQueue {
|
||||
pending: parking_lot::Mutex<Vec<SystemNotification>>,
|
||||
}
|
||||
|
||||
impl NotificationQueue {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pending: parking_lot::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&self, notification: SystemNotification) {
|
||||
self.pending.lock().push(notification);
|
||||
}
|
||||
|
||||
pub fn drain(&self) -> Vec<SystemNotification> {
|
||||
std::mem::take(&mut *self.pending.lock())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NotificationQueue {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for NotificationQueue {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
let count = self.pending.lock().len();
|
||||
f.debug_struct("NotificationQueue")
|
||||
.field("pending_count", &count)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn job_notification_success_shape() {
|
||||
let event = job_notification("job_a1b2", "execute_command", true);
|
||||
assert_eq!(
|
||||
event.to_value(),
|
||||
json!({
|
||||
"event": "job_completed",
|
||||
"id": "job_a1b2",
|
||||
"tool_or_agent": "execute_command",
|
||||
"status": "success",
|
||||
"next_action": "job__collect --id job_a1b2 for output",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_notification_failure_shape() {
|
||||
let event = job_notification("job_a1b2", "execute_command", false);
|
||||
assert_eq!(event.event, "job_failed");
|
||||
assert_eq!(event.status, "failed");
|
||||
assert_eq!(event.next_action, "job__collect --id job_a1b2 for output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_notification_success_shape() {
|
||||
let event = agent_notification("agent_explore_a1b2", "explore", true);
|
||||
assert_eq!(
|
||||
event.to_value(),
|
||||
json!({
|
||||
"event": "agent_completed",
|
||||
"id": "agent_explore_a1b2",
|
||||
"tool_or_agent": "explore",
|
||||
"status": "success",
|
||||
"next_action": "agent__collect --id agent_explore_a1b2 for output",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_notification_failure_shape() {
|
||||
let event = agent_notification("agent_explore_a1b2", "explore", false);
|
||||
assert_eq!(event.event, "agent_failed");
|
||||
assert_eq!(event.status, "failed");
|
||||
assert_eq!(
|
||||
event.next_action,
|
||||
"agent__collect --id agent_explore_a1b2 for output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_empties_queue_and_preserves_order() {
|
||||
let queue = NotificationQueue::new();
|
||||
queue.push(job_notification("job_1", "execute_command", true));
|
||||
queue.push(job_notification("job_2", "execute_command", false));
|
||||
|
||||
let drained = queue.drain();
|
||||
|
||||
assert_eq!(drained.len(), 2);
|
||||
assert_eq!(drained[0].id, "job_1");
|
||||
assert_eq!(drained[1].id, "job_2");
|
||||
assert!(queue.drain().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_on_empty_queue_is_a_noop() {
|
||||
let queue = NotificationQueue::default();
|
||||
assert!(queue.drain().is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user