Compare commits

120 Commits
Author SHA1 Message Date
Dark-Alex-17 93f9c5425e fix: npm and npx need the /usr/local/share/npm-global/lib directory to exist to run properly so I've added it to the dockerfile
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-22 17:11:57 -06:00
Dark-Alex-17 77dfd08103 fix: added executable bit to adversary agent tools script 2026-07-22 16:38:42 -06:00
Dark-Alex-17 46dcef0dec Merge branch 'main' of github.com:Dark-Alex-17/coyote 2026-07-22 16:37:34 -06:00
Dark-Alex-17 72c6bb74c2 feat: created the adversay agent and adversarial-review skill 2026-07-22 16:35:00 -06:00
Dark-Alex-17 6bf80dcce9 feat: added ast_grep tool to Sisyphus suite agents
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-22 15:41:09 -06:00
Dark-Alex-17 df948c69bf feat: new spawnable_agents field in agents to let users restrict what agents can be spawned by a parent agent
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-22 14:29:55 -06:00
Dark-Alex-17 e5d0fcc764 test: fixed linter issues on markdown tests
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-22 13:32:37 -06:00
Dark-Alex-17 c60d3e7cda style: updated some stylistic things across the new markdown rendering implementation
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-22 13:20:56 -06:00
Dark-Alex-17 c21fd47b42 feat: replay pre-compressed messages as well when resuming sessions for users to see 2026-07-22 13:05:06 -06:00
Dark-Alex-17 ab0b89dd67 fix: fetch descriptions from graph agent configs as well when listing agents 2026-07-22 12:59:30 -06:00
Dark-Alex-17 420db4bb88 feat: created a new builtin function for agents who can spawn other agents to list available agents via agent__list_available 2026-07-22 12:50:41 -06:00
Dark-Alex-17 dfacf31f6a fix(render): suppress blank lines above rendered table 2026-07-22 12:37:30 -06:00
Dark-Alex-17 8cfd5ee2c4 docs(render): update phase 2.7 SHA after amend 2026-07-22 12:30:34 -06:00
Dark-Alex-17 e82e5ab8e4 test(render): comprehensive table rendering coverage 2026-07-22 12:30:10 -06:00
Dark-Alex-17 d790782ace feat(render): hanging-indent line wrapping for lists and blockquotes 2026-07-22 12:27:13 -06:00
Dark-Alex-17 bf06d5e8f3 feat(render): wire table state machine and finalize hook 2026-07-22 12:23:44 -06:00
Dark-Alex-17 cdfaa0f111 feat(render): render markdown tables with comfy-table 2026-07-22 12:20:02 -06:00
Dark-Alex-17 c062f34852 feat(render): parse table cells and column alignments 2026-07-22 12:16:05 -06:00
Dark-Alex-17 7671d28d6e feat(render): detect markdown table rows and separators 2026-07-22 12:14:45 -06:00
Dark-Alex-17 fcc4a1d2b5 feat(render): add comfy-table dependency and table border style 2026-07-22 12:11:04 -06:00
Dark-Alex-17 b0eeba110d test(render): comprehensive coverage for rich markdown renderer 2026-07-22 11:48:43 -06:00
Dark-Alex-17 d65d63ee50 feat(render): activate rich markdown renderer as default 2026-07-22 11:47:39 -06:00
Dark-Alex-17 9890cf0ddc feat(render): rich block-level markdown rendering (headings, quotes, lists, hr) 2026-07-22 11:45:03 -06:00
Dark-Alex-17 89db5b3887 feat(render): rich inline markdown rendering (bold, italic, code, links) 2026-07-22 11:41:35 -06:00
Dark-Alex-17 f40ba4ccbe feat(render): detect markdown block-level line types 2026-07-22 11:37:11 -06:00
Dark-Alex-17 d2940a8d32 feat(render): precompute markdown scope styles for rich rendering 2026-07-22 11:31:23 -06:00
Dark-Alex-17 ed7ad36475 feat: Created the raw_markdown configuration flag 2026-07-22 11:03:27 -06:00
Dark-Alex-17 393ed16963 fix: chown the full sandbox cache dir, not just the coyote subdir
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-22 10:08:00 -06:00
Dark-Alex-17 3f94d2003a fix: chown the whole coyote cache dir not just the oauth dir in the sandbox
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-22 09:48:11 -06:00
Dark-Alex-17 50ff9008fe chore: updated deepseek models
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-22 09:31:17 -06:00
Dark-Alex-17 9606d7f8aa feat: added a .fork command to fork a new session from a running conversation
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-21 14:54:55 -06:00
Dark-Alex-17 d8ae9e25d9 docs: reworded explore agent instructions to empower agent to spawn as many sub agents as it deems necessary
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-21 14:14:00 -06:00
Dark-Alex-17 006f64bfa0 docs: Improved sisyphus wording to empower agent to spawn as many subagents as necessary 2026-07-21 14:12:51 -06:00
Dark-Alex-17 000559bc9d style: Applied formatting
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-21 11:24:18 -06:00
Dark-Alex-17 3aede58a11 feat(oauth): enable browser-paste PKCE flow for OpenAI-compatible providers
Two coordinated changes that make openai-compatible OAuth providers usable
with a non-localhost redirect_uri (browser shows the callback URL, user
copies it back into the terminal — the same UX Claude uses).

Fix: OpenAICompatibleOAuthProvider::fixed_redirect_uri() previously returned
Some(uri) for any redirect_uri including public HTTPS URLs, which trapped
run_pkce_flow into trying to bind a TCP listener on a public URL. It now
returns Some only for loopback URIs (127.0.0.1, localhost, ::1). Non-loopback
URIs return None, routing run_pkce_flow to the paste branch.

New tri-format paste parser (parse_paste_input):
- Full callback URL (starts with http:// or https://): parse code + state from
  the query string. This is what most modern OAuth providers redirect to and
  what a naive user copies from the browser bar.
- Anthropic-style code#state fragment: preserved for Claude compatibility.
- Bare code: accepted with a warning that CSRF state validation is skipped.
  For providers whose callback page shows only the code with no state.

State validation moved from mandatory to conditional — if a paste didn't
carry state (bare-code path), we warn and skip the check instead of hard-
failing. The listener path (localhost + LAN redirects) still requires state
because the server sends it in the query.

Adds 9 unit tests covering both changes.
2026-07-21 11:14:55 -06:00
Dark-Alex-17 cab1e72b97 fix: fix typo in Gemini's generation_config property to use camelCase exclusively 2026-07-21 11:11:00 -06:00
Dark-Alex-17 cd4bf245e9 chore: updated models.yaml 2026-07-21 11:09:51 -06:00
Dark-Alex-17 82bf6176f8 fix(oauth): treat missing expires_in as non-expiring device_code token
GitHub OAuth Apps issue tokens that never expire and omit expires_in from
the response (they only send access_token, token_type, scope). RFC 6749 §5.1
allows this — expires_in is only REQUIRED for tokens that actually expire.

When expires_in is missing, save the token with expires_at = i64::MAX so
prepare_oauth_access_token never tries to refresh. If the token is ever
revoked server-side, the eventual 401 on the API call is the user's cue
to re-authenticate.

No effect on providers that include expires_in (Moonshot etc. — unchanged).
2026-07-21 10:32:47 -06:00
Dark-Alex-17 d407eb5a6a fix(oauth): send Accept: application/json in device flow requests
GitHub's device flow endpoints (and likely other RFC 8628 servers) default
to responding in application/x-www-form-urlencoded unless the client asks
for JSON via the Accept header. Our device auth and polling paths both call
.json() on the response and were failing to decode form-urlencoded bodies
with 'expected value at line 1 column 1'.

Adds Accept: application/json to:
- The device authorization POST in run_device_code_flow
- The device_code polling POST (on the RequestBuilder returned by build_token_request)

RFC 6749 §5.1 already specifies JSON as the token response format, so this
is spec-compliant across providers. Servers that already default to JSON
(Moonshot, etc.) ignore the redundant header.
2026-07-21 10:30:36 -06:00
Dark-Alex-17 6f2594712f refactor: Standardized paths module function names to not use 'path' in the name and to just always be either 'dir' or 'file'
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-21 10:15:42 -06:00
Dark-Alex-17 79d43c8791 docs: config.example.yaml example for Device Authorization Grant
Adds a commented example under the openai-compatible client section showing
how to configure flow: device_code for RFC 8628 device flow. Uses Moonshot's
kimi-code endpoints as the illustrative reference (users supply their own
client_id — no bundled defaults per §5.8 of the design plan).
2026-07-20 15:27:20 -06:00
Dark-Alex-17 5a5da90734 test: unit tests for OAuthFlow::DeviceCode and merge behavior
Adds 9 unit tests covering:
- yaml deserialization of flow: device_code
- merge preserves base device_authorization_url when user omits
- merge lets user device_authorization_url win
- merge lets user use_pkce_in_device_flow win
- OpenAICompatibleOAuthProvider exposes / defaults both new trait methods
- Full serde roundtrip of a realistic device_code yaml block

No network or polling — pure config/serde logic tests. Brings the test
count from 1134 to 1143.
2026-07-20 15:25:24 -06:00
Dark-Alex-17 f2a0e7453e feat: copy host OAuth tokens into sandbox at launch
Projects ~/.cache/coyote/oauth/ from the host into /home/agent/.cache/coyote/oauth/
inside the sandbox so agents can call OAuth-authenticated providers without
re-authenticating. Same trust model as the existing config-dir and vault-password
copies. One-way copy (not bind-mount) — matches Docker's universal support
surface. Refreshed tokens die with the sandbox instance; run coyote --authenticate
inside if a fresh token is needed (Device Flow works via the QR code render).
2026-07-20 15:23:52 -06:00
Dark-Alex-17 0fe430102a feat: implement OAuth 2.0 Device Authorization Grant (RFC 8628)
Adds a third OAuthFlow variant (device_code) alongside the existing pkce and
client_credentials flows. Device flow enables OAuth for headless environments
where a browser-based callback listener isn't available — the user visits a
verification URL on any device and enters a short user_code.

- OAuthFlow::DeviceCode variant + serde 'device_code' string
- OAuthConfig fields: device_authorization_url, use_pkce_in_device_flow
- OAuthProvider trait: device_authorization_url() / use_pkce_in_device_flow()
- OpenAICompatibleOAuthProvider passes both through from config
- run_device_code_flow() polls the token endpoint per RFC 8628 §3.4–§3.5:
  handles authorization_pending, slow_down (+5s backoff), expired_token,
  access_denied, and unknown errors distinctly
- Sandbox-gated QR code display (via qrcode crate) — scanning with a phone
  is dramatically faster than copy-pasting the URL from a container
- Optional PKCE per draft-ietf-oauth-device-flow §5.4 (default off)
- run_oauth_flow and prepare_oauth_access_token dispatchers wire DeviceCode
  in; refresh path shared with PKCE since both flows produce refresh_tokens
2026-07-20 15:21:32 -06:00
Dark-Alex-17 d13bd32fdf chore: add qrcode dependency 2026-07-20 15:13:40 -06:00
Dark-Alex-17 1f1729ba00 chore: added new kimi models
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-20 14:35:26 -06:00
Dark-Alex-17 107419966d style: Cleaned up some comments and imports 2026-07-20 13:46:41 -06:00
Dark-Alex-17 344ef7526f feat: hint that browser 'paste code' pages can be ignored during callback capture 2026-07-20 13:29:37 -06:00
Dark-Alex-17 13d31f850c fix: OAuth callback listener skips speculative/malformed browser connections 2026-07-20 13:21:44 -06:00
Dark-Alex-17 f6bd02dc73 feat: openai-compatible wizard offers OAuth when provider has bundled oauth defaults 2026-07-20 13:17:26 -06:00
Dark-Alex-17 31df1a720d test: unit tests for OAuthConfig merge + get_oauth_provider_for_client 2026-07-20 13:12:38 -06:00
Dark-Alex-17 4e0e65fc8a docs: config.example.yaml OAuth examples for openai-compatible 2026-07-20 13:09:50 -06:00
Dark-Alex-17 ab85a4f534 feat: validate unique client names at config load 2026-07-20 13:07:41 -06:00
Dark-Alex-17 420447275c refactor: main.rs resolve_oauth_client uses new dispatcher 2026-07-20 13:05:57 -06:00
Dark-Alex-17 cdc40f7302 feat: bundle xAI OAuth defaults in models.yaml 2026-07-20 13:03:15 -06:00
Dark-Alex-17 c611685033 feat: OAuth branch in openai_compatible prepare_* fns 2026-07-20 13:01:28 -06:00
Dark-Alex-17 cac2a3eba0 feat: get_oauth_provider_for_client dispatcher + client_config_info update 2026-07-20 12:57:10 -06:00
Dark-Alex-17 66bbb34d7f feat: OpenAICompatibleOAuthProvider (config-driven OAuthProvider impl) 2026-07-20 12:55:30 -06:00
Dark-Alex-17 68177fdb6a feat: add auth + oauth fields to OpenAICompatibleConfig 2026-07-20 12:51:09 -06:00
Dark-Alex-17 1acaad223f feat: add oauth field to ProviderModels 2026-07-20 12:46:18 -06:00
Dark-Alex-17 aa0270602d feat: add client_credentials support to prepare_oauth_access_token 2026-07-20 12:44:53 -06:00
Dark-Alex-17 4669958bdd refactor: split run_oauth_flow into pkce + client_credentials dispatchers 2026-07-20 12:43:53 -06:00
Dark-Alex-17 559107073d feat: add OAuthConfig + OAuthFlow types to oauth.rs 2026-07-20 12:41:24 -06:00
Dark-Alex-17 d0a38747e0 chore: updated models.yaml
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-20 11:55:00 -06:00
Dark-Alex-17 677bd71b93 docs: added brew trust command to install example for iwe
CI / All (ubuntu-latest) (push) Failing after 26s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-19 15:24:13 -06:00
Dark-Alex-17 ad6d0a2e0e fix: resolve reasoning effort for the prompt for global defaults as well
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-17 17:52:38 -06:00
Dark-Alex-17 50911b99ef fix: Account for default model reasoning_effort when supplying that value for the REPL prompts
CI / All (ubuntu-latest) (push) Failing after 26s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-17 17:42:10 -06:00
Dark-Alex-17 44783c5573 feat: Added reasoning effort to the right prompt
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-17 17:34:10 -06:00
Dark-Alex-17 8629c1ca15 feat: Improved support for Anthropic's extended thinking 2026-07-17 17:26:41 -06:00
Dark-Alex-17 078e6e3744 fix: model narration included in history and between tool calls to prevent repetition
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-17 16:57:51 -06:00
Dark-Alex-17 b908fc20ba docs: Added a docker pulls tracker badge
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-17 16:24:07 -06:00
Dark-Alex-17 058810137c fix: Don't terminate agent loops early for null tool output
CI / All (ubuntu-latest) (push) Failing after 26s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-17 16:17:29 -06:00
Dark-Alex-17 c979041161 fix: reduce code duplication by reusing the new concrete_tool_names function in .list tools
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-17 15:51:01 -06:00
Dark-Alex-17 a606ea552d fix: Agent tools can only be modified via .tool enable/disable using tools in the allowed whitelist in the agent 2026-07-17 15:42:57 -06:00
Dark-Alex-17 39a654a79e fix: re-render agent sessions when entering agents with either pre-configured agent_session or when entering an agent directly into a session 2026-07-17 15:08:19 -06:00
Dark-Alex-17 17d1decce6 feat: Also support GEMINI.md workspace instructions 2026-07-17 15:05:16 -06:00
Dark-Alex-17 a45e66c634 feat: Improved workspace instructions support 2026-07-17 14:52:35 -06:00
Dark-Alex-17 8c885d9a77 feat: also detect .mcp.json configurations at workspace roots 2026-07-17 14:03:08 -06:00
Dark-Alex-17 6dd1e59815 fix: Per RFC 9728, enable dynamic discovery of OAuth endpoints in MCP using path-aware discovery
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-17 13:28:55 -06:00
Dark-Alex-17 f5085a773a fix: hot-attach to MCP servers that require auth after running .mcp auth <name> 2026-07-17 13:16:25 -06:00
Dark-Alex-17 09afdeaf7c feat: Created new .tool enable/disable and .mcp enable/disable aliases to make REPL usage more egonomic 2026-07-17 12:53:59 -06:00
Dark-Alex-17 0216d84eee feat: Created a new .list <kind> REPL command to make discoveribility easier in the REPL 2026-07-17 11:49:49 -06:00
Dark-Alex-17 320dbf2479 fix: Correctly inherit graph-global model for extractor model if none is defined 2026-07-17 11:42:28 -06:00
Dark-Alex-17 6958e9cba8 feat: Support claude-style hidden workspace MCP configuration files via .mcp.json
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-17 10:46:26 -06:00
Dark-Alex-17 825f9f6bf5 feat: Allow users to customize the workspace-specific configuration directory name so they can use Coyote with other CLI clients like .claude 2026-07-17 10:38:35 -06:00
Dark-Alex-17 863740f916 fix: no cursor timeout when user scrolls away from ongoing streaming output
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-16 16:01:19 -06:00
Dark-Alex-17 304088bf5c tests: Added tests for graph-based RAG
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-16 14:33:52 -06:00
Dark-Alex-17 b7599b8acf build: Added just recipe for building the multi-platform image
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-16 13:33:47 -06:00
Dark-Alex-17 9b3ae761f3 feat: Add reasoning_effort validation for the main configuration file
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-16 13:18:28 -06:00
Dark-Alex-17 0f7877aafc feat: Add validation for reasoning_effort settings to prevent users from specifying erroneous values 2026-07-16 13:12:11 -06:00
Dark-Alex-17 5843a9ac15 docs: Fixed broken links in the code-review and file-reviewer agent READMEs 2026-07-16 13:05:39 -06:00
Dark-Alex-17 4bfaabcb99 docs: Added the reasoning_effort field to example configuration files 2026-07-16 13:05:23 -06:00
Dark-Alex-17 f16f858074 Merge branch 'main' of github.com:Dark-Alex-17/coyote
# Conflicts:
#	src/repl/mod.rs
2026-07-16 12:30:21 -06:00
Dark-Alex-17 e9a8c01dc4 feat: Added support for modifying the reasoning effort of reasoning models 2026-07-16 12:28:04 -06:00
Dark-Alex-17 5bbf1b2d71 test: updated repl tests for undo command 2026-07-15 17:00:01 -06:00
Dark-Alex-17 e9c52566b8 feat: Explicitly Prevent .undo usage in graph agents
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-15 16:27:57 -06:00
Dark-Alex-17 4c7de650c0 feat: Added an .undo command to the REPL to let users have more control over the conversation 2026-07-15 16:23:59 -06:00
Dark-Alex-17 6127d964ee chore: update models.yaml 2026-07-15 16:14:33 -06:00
Dark-Alex-17 8bbbd71fec fix: default to the nano or notepad when a configured editor is not found
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-15 15:32:07 -06:00
Dark-Alex-17 7f89a80f7e fix: When EDITOR, VISUAL, or config.editor is defined, don't verify via which
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-15 15:16:41 -06:00
Dark-Alex-17 19cca06db6 ci: bump the coyote image tag version in the sandbox kit spec
CI / All (ubuntu-latest) (push) Failing after 27s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-15 13:24:51 -06:00
Dark-Alex-17 e8df9f119c feat: Improve sandbox startup time by using the prebuilt Coyote image 2026-07-15 13:24:37 -06:00
Dark-Alex-17 8abe297bfe feat: Make coyote available as a docker image 2026-07-15 13:24:21 -06:00
Dark-Alex-17 4ec6daff30 fix: Added a loop exit condition for the diagnostics skill
CI / All (ubuntu-latest) (push) Failing after 26s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-15 11:51:06 -06:00
Dark-Alex-17 9c1067e544 fix: Added directness clause to the diganose role to improve prompt 2026-07-15 11:14:15 -06:00
Dark-Alex-17 2fe6704fbc fix: fs tools now output better error handling to guide the model more effectively
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-14 12:47:43 -06:00
Dark-Alex-17 dd40892ad5 fix: Make fs_read more tolerant of various arg invocation formats. 2026-07-14 12:31:24 -06:00
Dark-Alex-17 ed86b7bfc3 Merge branch 'main' of github.com:Dark-Alex-17/coyote 2026-07-14 11:31:21 -06:00
Dark-Alex-17 f32d72a3f2 feat: Made fs_patch more flexible for different model preferences of patch formats 2026-07-14 11:31:11 -06:00
Dark-Alex-17 7b00638476 style: removed redundant '&' from functions module 2026-07-13 18:11:03 -06:00
Dark-Alex-17 6733b3600f test: Fixed flaky python AST parser test for macOS
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-13 17:46:34 -06:00
Dark-Alex-17 de6010d525 docs: Organized coyote --help output to be more readable
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-13 17:31:20 -06:00
Dark-Alex-17 9b0e26bade feat: Installed nano into the sandbox so that users can edit config files in the sandbox directly 2026-07-13 17:29:10 -06:00
Dark-Alex-17 ac40043c00 style: Removed outdated implementation plan 2026-07-13 17:25:20 -06:00
Dark-Alex-17 d8eec1d427 docs: Documented the new no_workspace_mcp configuration property that disables workspace-local MCP configurations
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-13 17:14:06 -06:00
Dark-Alex-17 382916c3ee style: Removed redundant '&' from paths module function calls 2026-07-13 17:12:58 -06:00
Dark-Alex-17 bc3cc10a7b feat: Support workspace-local skill definitions and MCP configurations 2026-07-13 17:12:34 -06:00
Dark-Alex-17 b91f738209 docs: updated the configuratino examples for graph-based RAG
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-13 16:55:18 -06:00
Dark-Alex-17 4f0dae9b49 feat: fully functional graph-based RAG
CI / All (ubuntu-latest) (push) Failing after 26s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-13 16:50:07 -06:00
Dark-Alex-17 deb673ebc9 fmt: applied some formatting changes 2026-07-13 16:07:19 -06:00
90 changed files with 9222 additions and 1380 deletions
+78 -16
View File
@@ -8,9 +8,9 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
bump_type: bump_type:
description: "Specify the type of version bump" description: 'Specify the type of version bump'
required: true required: true
default: "patch" default: 'patch'
type: choice type: choice
options: options:
- patch - patch
@@ -46,7 +46,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: '3.10'
- name: Install Commitizen - name: Install Commitizen
run: | run: |
@@ -108,17 +108,19 @@ jobs:
cargo update || true cargo update || true
sed -i "s|image: 'darkalex17/coyote:v[^']*'|image: 'darkalex17/coyote:v${VERSION}'|" assets/sbx-kit/spec.yaml
# Git config that helps in Act # Git config that helps in Act
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git config --global --add safe.directory "$GITHUB_WORKSPACE" git config --global --add safe.directory "$GITHUB_WORKSPACE"
git status --porcelain git status --porcelain
git diff --name-only -- Cargo.toml Cargo.lock || true git diff --name-only -- Cargo.toml Cargo.lock assets/sbx-kit/spec.yaml || true
if ! git diff --quiet -- Cargo.toml Cargo.lock; then if ! git diff --quiet -- Cargo.toml Cargo.lock assets/sbx-kit/spec.yaml; then
git add -u -- Cargo.toml Cargo.lock git add -u -- Cargo.toml Cargo.lock assets/sbx-kit/spec.yaml
git commit -m "chore: bump Cargo.toml to $VERSION" git commit -m "chore: bump Cargo.toml and sandbox image to $VERSION"
else else
echo "No changes to commit (already at $VERSION)" echo "No changes to commit (already at $VERSION)"
fi fi
@@ -163,28 +165,28 @@ jobs:
- target: aarch64-unknown-linux-musl - target: aarch64-unknown-linux-musl
os: ubuntu-latest os: ubuntu-latest
use-cross: true use-cross: true
cargo-flags: "" cargo-flags: ''
- target: aarch64-apple-darwin - target: aarch64-apple-darwin
os: macos-latest os: macos-latest
use-cross: true use-cross: true
cargo-flags: "" cargo-flags: ''
- target: aarch64-pc-windows-msvc - target: aarch64-pc-windows-msvc
os: windows-latest os: windows-latest
use-cross: true use-cross: true
cargo-flags: "" cargo-flags: ''
- target: x86_64-apple-darwin - target: x86_64-apple-darwin
os: macos-latest os: macos-latest
cargo-flags: "" cargo-flags: ''
- target: x86_64-pc-windows-msvc - target: x86_64-pc-windows-msvc
os: windows-latest os: windows-latest
cargo-flags: "" cargo-flags: ''
- target: x86_64-unknown-linux-musl - target: x86_64-unknown-linux-musl
os: ubuntu-latest os: ubuntu-latest
use-cross: true use-cross: true
cargo-flags: "" cargo-flags: ''
- target: x86_64-unknown-linux-gnu - target: x86_64-unknown-linux-gnu
os: ubuntu-latest os: ubuntu-latest
cargo-flags: "" cargo-flags: ''
steps: steps:
- name: Check if actor is repository owner - name: Check if actor is repository owner
@@ -338,7 +340,7 @@ jobs:
${{ steps.package.outputs.archive }} ${{ steps.package.outputs.archive }}
${{ steps.package.outputs.sha }} ${{ steps.package.outputs.sha }}
tag_name: v${{ env.RELEASE_VERSION }} tag_name: v${{ env.RELEASE_VERSION }}
name: "v${{ env.RELEASE_VERSION }}" name: 'v${{ env.RELEASE_VERSION }}'
body_path: artifacts/changelog.md body_path: artifacts/changelog.md
prerelease: false prerelease: false
@@ -455,4 +457,64 @@ jobs:
- uses: katyo/publish-crates@v2 - uses: katyo/publish-crates@v2
if: env.ACT != 'true' if: env.ACT != 'true'
with: with:
registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }} registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }}
publish-sandbox-image:
needs: [publish-github-release]
name: Publish Sandbox Docker Image
runs-on: ubuntu-latest
steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner && env.ACT != 'true' }}
run: |
echo "You are not authorized to run this workflow."
exit 1
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Ensure repository is up-to-date
if: env.ACT != 'true'
run: |
git fetch --all
git pull
- name: Get release artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Set version variable
run: |
version="$(cat artifacts/release-version)"
echo "version=$version" >> $GITHUB_ENV
- name: Validate release environment variables
run: |
echo "Release version: ${{ env.version }}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
if: env.ACT != 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Push to Docker Hub
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ env.ACT != 'true' }}
tags: darkalex17/coyote:latest, darkalex17/coyote:${{ env.version }}
build-args: COYOTE_VERSION=${{ env.version }}
-371
View File
@@ -1,371 +0,0 @@
# Graph RAG Design Spec
## Status: COMPLETE
### Verified From Code (all claims backed by actual file reads)
---
## Goal
Extend the existing two-signal hybrid search (vector HNSW + BM25 → RRF) to a three-signal hybrid
(vector + BM25 + knowledge graph → RRF). The graph captures entity/relationship knowledge extracted
from documents at ingestion time via an LLM call per chunk. At query time, graph traversal expands
context beyond semantic similarity.
---
## Verified Current Architecture
### `Rag` struct (`src/rag/mod.rs:48`)
```rust
pub struct Rag {
app_config: Arc<AppConfig>,
name: String,
path: String,
embedding_model: Model,
hnsw: Hnsw<'static, f32, DistCosine>, // ephemeral, rebuilt on load
bm25: SearchEngine<DocumentId>, // ephemeral, rebuilt on load
data: RagData, // serialized to YAML
last_sources: RwLock<Option<String>>,
}
```
### `RagData` struct (`src/rag/mod.rs:892`)
```rust
pub struct RagData {
pub embedding_model: String,
pub chunk_size: usize,
pub chunk_overlap: usize,
pub reranker_model: Option<String>,
pub top_k: usize,
pub batch_size: Option<usize>,
pub next_file_id: FileId,
pub document_paths: Vec<String>,
pub files: IndexMap<FileId, RagFile>,
#[serde(with = "serde_vectors")]
pub vectors: IndexMap<DocumentId, Vec<f32>>,
}
```
### `RagData::new` callers (both need updating):
1. `Rag::init` (`src/rag/mod.rs:219`) — interactive init path
2. `Rag::resolve_init_data` (`src/rag/mod.rs:195`) — config-driven init path
### `Rag::create` (`src/rag/mod.rs:253`) — all init paths converge here:
```rust
pub fn create(app: &AppConfig, name: &str, path: &Path, data: RagData) -> Result<Self> {
let hnsw = data.build_hnsw();
let bm25 = data.build_bm25();
let embedding_model = Model::retrieve_model(app, &data.embedding_model, ModelType::Embedding)?;
let rag = Rag { app_config: Arc::new(app.clone()), name: name.to_string(),
path: path.display().to_string(), data, embedding_model, hnsw, bm25,
last_sources: RwLock::new(None) };
Ok(rag)
}
```
### `hybrid_search` (`src/rag/mod.rs:710`)
```rust
async fn hybrid_search(&self, query: &str, top_k: usize, rerank_model: Option<&str>)
-> Result<Vec<(DocumentId, String)>>
```
Runs `vector_search` + `keyword_search` in parallel via `tokio::join!`, then either reranks or
applies `reciprocal_rank_fusion(vec![vector_ids, keyword_ids], vec![1.125, 1.0], top_k)`.
### `reciprocal_rank_fusion` (`src/rag/mod.rs:1186`) — standalone fn, already weight-parameterized:
```rust
fn reciprocal_rank_fusion(
list_of_document_ids: Vec<Vec<DocumentId>>,
list_of_weights: Vec<f32>,
top_k: usize,
) -> Vec<DocumentId>
```
### `RagData::del` (`src/rag/mod.rs:953`):
```rust
pub fn del(&mut self, file_ids: Vec<FileId>) {
for file_id in file_ids {
if let Some(file) = self.files.swap_remove(&file_id) {
for (document_index, _) in file.documents.iter().enumerate() {
let document_id = DocumentId::new(file_id, document_index);
self.vectors.swap_remove(&document_id);
}
}
}
}
```
### `RagNode` (`src/graph/types.rs:331`):
```rust
pub struct RagNode {
pub documents: Vec<String>,
pub query: Option<String>,
pub top_k: Option<usize>,
pub embedding_model: Option<String>,
pub chunk_size: Option<usize>,
pub chunk_overlap: Option<usize>,
pub reranker_model: Option<String>,
pub batch_size: Option<usize>,
pub state_updates: Option<HashMap<String, String>>,
pub timeout: Option<u64>,
}
```
### `Client` trait (`src/client/common.rs:40`):
- `async fn chat_completions(&self, input: Input) -> Result<ChatCompletionsOutput>` — needs `Input`
- `async fn chat_completions_inner(&self, client: &ReqwestClient, data: ChatCompletionsData) -> Result<ChatCompletionsOutput>` — accessible on `Box<dyn Client>` via vtable
- `async fn embeddings(&self, data: &EmbeddingsData) -> Result<Vec<Vec<f32>>>`
- `async fn rerank(&self, data: &RerankData) -> Result<RerankOutput>`
- `fn build_client(&self) -> Result<ReqwestClient>`
- `fn model(&self) -> &Model`
**Key finding**: `Input` cannot be constructed without `RequestContext` (which `Rag` doesn't have).
Instead, `extract_entities` uses `chat_completions_inner` directly with manually built
`ChatCompletionsData`. This is accessible via `Box<dyn Client>`.
### `Message` (`src/client/message.rs:22`):
```rust
pub fn new(role: MessageRole, content: MessageContent) -> Self
```
`MessageRole::User`, `MessageContent::Text(String)` — both confirmed.
### `AppConfig` RAG fields (`src/config/app_config.rs:71`):
```rust
pub rag_embedding_model: Option<String>,
pub rag_reranker_model: Option<String>,
pub rag_top_k: usize, // default: 5
pub rag_chunk_size: Option<usize>,
pub rag_chunk_overlap: Option<usize>,
pub rag_template: Option<String>,
```
### `patch_messages` — confirmed exported from `crate::client::*` (used in `input.rs:5`)
### `init_client(app_config, model)` — works for any `ModelType`, including `Chat`
### `ModelType` variants: `Chat`, `Embedding`, `Reranker` (confirmed in `model.rs`)
### petgraph serde: `NodeIndex` serializes as inner `u32`; `StableGraph` preserves index positions
through roundtrip. `IndexMap<DocumentId, Vec<NodeIndex>>` safe for YAML (DocumentId is newtype over
usize, serializes as integer key).
---
## New Dependency
```toml
petgraph = { version = "0.7", features = ["serde-1"] }
```
---
## New File: `src/rag/graph.rs`
All graph types and extraction logic. Module declared in `mod.rs` as `mod graph; use self::graph::*;`.
### Types:
- `Entity { name: String, entity_type: String, description: Option<String> }`
- `Relationship { relation_type: String, weight: f32 }`
- `ExtractionResult { entities: Vec<ExtractedEntity>, relationships: Vec<ExtractedRelationship> }`
- `ExtractedEntity { name: String, r#type: String, description: Option<String> }`
- `ExtractedRelationship { from: String, to: String, r#type: String, weight: Option<f32> }`
- `KnowledgeGraph { graph: StableGraph<Entity, Relationship>, entity_index: IndexMap<String, NodeIndex>, document_entities: IndexMap<DocumentId, Vec<NodeIndex>> }`
### Key methods on `KnowledgeGraph`:
- `merge(doc_id: DocumentId, result: ExtractionResult)` — merges extraction into graph
- `remove_documents(ids: &[DocumentId])` — removes entities exclusive to deleted documents
- `build_node_to_docs(&self) -> IndexMap<NodeIndex, Vec<DocumentId>>` — ephemeral reverse map
### `extract_entities(client: &dyn Client, chunk: &str) -> Result<ExtractionResult>`:
- Builds `ChatCompletionsData` manually (no `Input` needed)
- Calls `patch_messages` then `client.chat_completions_inner(&reqwest_client, data).await`
- Strips markdown code fences from response before JSON parse
- Temperature: `Some(0.0)` for deterministic extraction
### Extraction prompt: structured JSON output requesting entities + relationships
---
## Changes to `src/rag/mod.rs`
### `Rag` struct — add one ephemeral field:
```rust
node_to_docs: IndexMap<NodeIndex, Vec<DocumentId>>, // ephemeral, rebuilt on load
```
### `Rag::create` — build node_to_docs before moving data:
```rust
let node_to_docs = data.knowledge_graph.build_node_to_docs();
// then add to struct literal
```
### `Rag` Clone impl — add:
```rust
node_to_docs: self.data.knowledge_graph.build_node_to_docs(),
```
### `RagData` struct — three new fields (all `#[serde(default)]` for backward compat):
```rust
#[serde(default)]
pub graph_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extractor_model: Option<String>,
#[serde(default)]
pub knowledge_graph: KnowledgeGraph,
```
### `RagData::new` — two new params: `graph_enabled: bool, extractor_model: Option<String>`
### `RagData::del` — collect doc_ids during existing loop, call `remove_documents` at end:
```rust
let mut doc_ids_to_remove = vec![];
for file_id in file_ids {
if let Some(file) = self.files.swap_remove(&file_id) {
for (document_index, _) in file.documents.iter().enumerate() {
let document_id = DocumentId::new(file_id, document_index);
self.vectors.swap_remove(&document_id);
doc_ids_to_remove.push(document_id);
}
}
}
self.knowledge_graph.remove_documents(&doc_ids_to_remove);
```
### `Rag::init` (line 219) — add two params to `RagData::new`:
```rust
app.rag_graph_enabled,
app.rag_extractor_model.clone(),
```
### `resolve_init_data` — resolve from config+app, pass to `RagData::new`:
```rust
let graph_enabled = config.graph_enabled.unwrap_or(app.rag_graph_enabled);
let extractor_model = config.extractor_model.clone().or_else(|| app.rag_extractor_model.clone());
```
### `sync_documents` — entity extraction block after `rag_files` built, before embedding:
```rust
if self.data.graph_enabled {
if let Some(extractor_model_id) = self.data.extractor_model.clone() {
let model = Model::retrieve_model(&self.app_config, &extractor_model_id, ModelType::Chat)?;
let client = self.create_embeddings_client(model)?;
let total_chunks: usize = rag_files.iter().map(|f| f.documents.len()).sum();
let mut chunk_num = 0;
let file_offset = next_file_id;
for (batch_file_idx, rag_file) in rag_files.iter().enumerate() {
let file_id = file_offset + batch_file_idx;
for (doc_idx, doc) in rag_file.documents.iter().enumerate() {
chunk_num += 1;
progress(&spinner, format!("Extracting entities [{chunk_num}/{total_chunks}]"));
let doc_id = DocumentId::new(file_id, doc_idx);
match extract_entities(client.as_ref(), &doc.page_content).await {
Ok(result) => self.data.knowledge_graph.merge(doc_id, result),
Err(e) => debug!("Entity extraction failed for {doc_id:?}: {e}"),
}
}
}
}
}
```
### After line 705 (after hnsw/bm25 rebuild in sync_documents):
```rust
self.node_to_docs = self.data.knowledge_graph.build_node_to_docs();
```
### `hybrid_search` — add third signal:
```rust
let graph_search_ids: Vec<DocumentId> = if self.data.graph_enabled
&& !self.data.knowledge_graph.entity_index.is_empty()
{
self.graph_search(query, &keyword_search_ids, top_k)
} else {
vec![]
};
// RRF: extend to 3-way when graph has results, fall back to 2-way otherwise
```
### New `graph_search` method (sync):
```rust
fn graph_search(&self, query: &str, bm25_anchor_ids: &[DocumentId], top_k: usize) -> Vec<DocumentId>
```
Phase 1: entity names from query via substring match in `entity_index`.
Phase 2: fallback — entities from top BM25 document chunks.
Phase 3: expand 1-hop neighbors in `StableGraph`.
Phase 4: score docs by entity overlap ratio, return top_k.
### `RagInitConfig` — two new fields:
```rust
pub graph_enabled: Option<bool>,
pub extractor_model: Option<String>,
```
---
## Changes to `src/config/app_config.rs`
New fields alongside existing `rag_*` block:
```rust
pub rag_graph_enabled: bool, // default: false
pub rag_extractor_model: Option<String>, // default: None
```
Defaults, env var overrides, and propagation all follow the same pattern as existing `rag_*` fields.
---
## Changes to `src/graph/types.rs` — `RagNode`
```rust
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extractor_model: Option<String>,
```
---
## Changes to `src/config/agent.rs`
Pass new fields through to `RagInitConfig`:
```rust
graph_enabled: rag_node.graph_enabled,
extractor_model: rag_node.extractor_model.clone(),
```
---
## Backward Compatibility
- All new `RagData` fields have `#[serde(default)]` — old YAML files load without migration
- `graph_enabled` defaults `false` — existing RAG instances unchanged
- `graph_search_ids` empty → 2-way RRF runs (identical to current behavior)
- `node_to_docs` rebuild on `create()` is O(n) over empty map for old instances
---
## V1 Scope Exclusions
- LLM entity extraction from query at search time (V1 uses substring match + BM25 anchoring)
- Multi-hop traversal (field reserved, 1-hop only in V1)
- Entity embeddings / fuzzy entity lookup
- Bincode for large-corpus graph storage
- Gleaning / multi-pass extraction
---
## Implementation Progress
- [x] Cargo.toml — petgraph dependency
- [x] src/rag/graph.rs — new file
- [x] src/rag/mod.rs — mod/use, Rag struct, create, clone
- [x] src/rag/mod.rs — RagData fields, new, del
- [x] src/rag/mod.rs — Rag::init, resolve_init_data
- [x] src/rag/mod.rs — sync_documents extraction block
- [x] src/rag/mod.rs — hybrid_search + graph_search
- [x] src/rag/mod.rs — RagInitConfig fields
- [x] src/config/app_config.rs — new fields
- [x] src/config/mod.rs — propagation
- [x] src/graph/types.rs — RagNode fields
- [x] src/config/agent.rs — propagation
- [x] cargo check — clean (0 warnings, 1065 tests passing)
@@ -0,0 +1,583 @@
# Rich Markdown Renderer — Phase 2: Tables + List Wrapping
**Status:** Planning complete, awaiting Momus review before implementation.
**Owner:** Coyote maintainer
**Estimated effort:** 3-4 days (tables + hanging-indent wrapping for lists/blockquotes)
**Related:** [Phase 1 plan](./rich-markdown-renderer.md) — must be complete first (it is).
---
## Goals
1. **Tables:** render GFM markdown tables (`| col | col |` with `|---|---|` separator rows) as styled terminal tables using box-drawing characters, respecting per-column alignment specifiers and the user's syntect theme colors. Match glamour's structural rendering (box borders, header separator, aligned cells). Cell content wraps within the column boundary.
2. **List wrapping:** when a bullet/numbered/task list item's content is longer than the wrap width, wrap it with a **hanging indent** so continuation lines align under the text, not under the bullet marker. Same treatment for blockquotes — continuation lines get the `│ ` prefix.
## Non-Goals
- **Not shipping without `comfy-table` dependency.** Hand-rolling table rendering requires reimplementing width-aware unicode + ANSI-aware column sizing. `comfy-table 7.2.2` already does this correctly (`ansi_strip().width()`) and is actively maintained (Jan 2026). See "Library decision" below.
- **Not supporting non-GFM table syntaxes.** Multi-line cells, cell merging, nested tables, and reStructuredText-style grid tables are out of scope. Standard GFM `|` + `---` only.
- **Not showing partial tables during streaming.** Tables accumulate silently while rows arrive; the rendered table appears once when the block ends. Users see a brief pause during accumulation instead of a flashing raw→rendered transition. Matches glamour behavior.
- **Not preserving the "zero touches outside `markdown.rs`" Phase 1 principle** — see "Scope-Expansion Rationale" below.
## Scope-Expansion Rationale
Phase 1 held two principles that Phase 2 must relax, both with clear justification:
1. **"No state beyond `LineType` code-block tracker."** Tables inherently need multi-line state (buffer rows until block ends). Contained to a single `Option<TableState>` field on `MarkdownRender`. No other state added.
2. **"Only `src/render/markdown.rs` changes."** Tables need an end-of-stream flush hook, which means 3 small callsite changes: `stream.rs` (streaming), `app_config.rs::print_markdown` (one-shot CLI), `session.rs::render` (session display). Each change is a single line: `output.push_str(&render.finalize())`.
These are necessary complexity, not scope creep. The plan explicitly recognizes them.
## Resolved Design Decisions
1. **Library:** use `comfy-table 7.2.2` with `custom_styling` feature enabled. Only Rust table library that correctly strips ANSI escapes before width computation (via `s.ansi_strip().width()` at `custom_styling.rs:10`). Alternatives (tabled, cli-table, term-table, prettytable-rs) either lack ANSI support, don't support arbitrary border colors, or are abandoned. Full survey in the librarian report.
2. **Streaming behavior:** silent accumulation. Table rows return empty string from renderer; buffered internally; rendered on block end. Matches glamour.
3. **Detection lookahead:** speculative table detection. First `|...|` line buffered as `PendingHeader`; next line's shape confirms (separator → commit to table) or rejects (anything else → flush both as paragraphs). Required for correctness — GFM demands separator row.
4. **Border color:** new `MarkdownStyles::table_border` field resolved from theme via scope `punctuation.definition.table.markdown` → fallback `punctuation` → fallback `hrule` (which already exists). Applies to all box-drawing chars uniformly.
5. **Header styling:** reuse existing `heading` style (bold + heading color) for header cells. No new field needed.
6. **Alignment specifiers:** parse `:---`, `---:`, `:---:` from separator row; map to `comfy_table::CellAlignment::Left/Right/Center`. Default (no colons) = left.
7. **Inline markdown in cells:** run `apply_inline()` on each cell before feeding to comfy-table. `custom_styling` feature ensures widths compute correctly on pre-styled text.
## Architecture
### New dependency
`Cargo.toml`:
```toml
comfy-table = { version = "7.2.2", features = ["custom_styling"] }
```
Pulls in `unicode-width` (already used indirectly), `unicode-segmentation`, and `ansi-str`. Total footprint small (~77KB crate).
### New MarkdownStyles field
`markdown.rs:612` — add one line to the struct:
```rust
pub struct MarkdownStyles {
// ... existing 11 fields ...
table_border: Color,
}
```
Resolved in `from_theme()` via `resolve_scope_style(theme, "punctuation.definition.table.markdown", &["punctuation", "meta.separator"], truecolor)`. Falls back to `hrule` color if scope not found. `None` case → default color.
### New LineKind variant
`markdown.rs:57` — extend enum:
```rust
pub enum LineKind {
// ... existing variants ...
TableRow, // any line matching ^\s*\|.*\|\s*$
TableSeparator, // subset of TableRow matching ^\s*\|(\s*:?-+:?\s*\|)+\s*$
}
```
Two variants because separator detection needs its own regex; keeping them distinct simplifies the state machine.
### Table detection regexes
Add to the `LazyLock` regex block:
```rust
// A line that looks like a table row: starts and ends with |, non-empty content
static TABLE_ROW: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\|.*\|\s*$").unwrap());
// The separator row that must follow a header: | :---: | ---: | :--- | ---- |
static TABLE_SEPARATOR: LazyLock<Regex> = LazyLock::new(|| Regex::new(
r"^\s*\|(\s*:?-{3,}:?\s*\|)+\s*$"
).unwrap());
```
Order in `detect_line_kind`: check `TABLE_SEPARATOR` before `TABLE_ROW` (separator is a subset of row).
### New TableState struct
Added to `markdown.rs`:
```rust
enum TableState {
/// Just saw a `|...|` line but haven't seen the separator yet.
/// If next line is a separator → transition to Active.
/// If next line is anything else → not a table; flush the header as paragraph + process next line.
PendingHeader(String),
/// Confirmed table. Accumulating data rows.
Active {
header: Vec<String>,
alignments: Vec<CellAlignment>,
rows: Vec<Vec<String>>,
},
}
```
### MarkdownRender state field
`markdown.rs:266` — add one field:
```rust
pub struct MarkdownRender {
// ... existing 8 fields ...
table_state: Option<TableState>,
}
```
Initialized to `None` in `init()`.
### State machine (in `render_line_mut`)
Runs BEFORE the existing branch on `is_code`/`raw_markdown`/rich:
```rust
fn render_line_mut(&mut self, line: &str) -> String {
let (line_type, line_kind, code_syntax, is_code) = self.check_line(line);
self.prev_line_type = line_type;
self.code_syntax = code_syntax;
// Table state machine — runs FIRST because tables preempt normal rendering
if let Some(output) = self.handle_table_state(line, line_kind) {
return output;
}
// ... existing code / raw_markdown / rich branch (unchanged) ...
}
fn handle_table_state(&mut self, line: &str, kind: LineKind) -> Option<String> {
match (&mut self.table_state, kind) {
// No pending table + saw a row → start pending
(None, LineKind::TableRow) => {
self.table_state = Some(TableState::PendingHeader(line.to_string()));
Some(String::new()) // silent accumulation
}
// No pending table + saw a separator (rare) → treat as paragraph
(None, LineKind::TableSeparator) => None,
// Pending header + saw separator → commit to Active
(Some(TableState::PendingHeader(header_line)), LineKind::TableSeparator) => {
let header = parse_table_row(&header_line);
let alignments = parse_alignments(line);
self.table_state = Some(TableState::Active { header, alignments, rows: vec![] });
Some(String::new())
}
// Pending header + saw another row (no separator) → not a table; flush both as paragraphs
(Some(TableState::PendingHeader(header_line)), LineKind::TableRow) => {
let flushed = std::mem::take(header_line).clone();
self.table_state = None;
let a = self.render_as_paragraph(&flushed);
let b = self.render_as_paragraph(line);
Some(format!("{a}\n{b}"))
}
// Pending header + saw anything else → not a table; flush header + process line normally
(Some(TableState::PendingHeader(_)), _) => {
let TableState::PendingHeader(header_line) = self.table_state.take().unwrap()
else { unreachable!() };
let flushed = self.render_as_paragraph(&header_line);
None // caller continues with normal rendering; prepend `flushed` in caller
// (implementation detail: needs to return Some(flushed + normal_render) — see impl)
}
// Active + saw a row → add to buffer
(Some(TableState::Active { rows, .. }), LineKind::TableRow) => {
rows.push(parse_table_row(line));
Some(String::new())
}
// Active + saw anything else → flush table + process line
(Some(TableState::Active { .. }), _) => {
let TableState::Active { header, alignments, rows } = self.table_state.take().unwrap()
else { unreachable!() };
let rendered = self.render_table(header, alignments, rows);
None // caller prepends rendered + processes line normally
// (same pattern as above)
}
(None, _) => None, // no table state to affect; normal rendering
}
}
```
**Note on the "prepend + continue" pattern**: for the flush-then-continue transitions, the cleanest implementation splits into `handle_table_state` returning `Option<String>` for the flushed-table portion, and the caller concatenates that with the normally-rendered current line. Implementation detail; the state transitions are what matter for design review.
### Cell parsing
Two helpers:
```rust
fn parse_table_row(line: &str) -> Vec<String> {
// Strip leading/trailing whitespace and the outer `|`
let inner = line.trim().trim_start_matches('|').trim_end_matches('|');
inner.split('|').map(|c| c.trim().to_string()).collect()
}
fn parse_alignments(separator_row: &str) -> Vec<CellAlignment> {
let cells = parse_table_row(separator_row);
cells.iter().map(|c| {
let trimmed = c.trim();
let starts = trimmed.starts_with(':');
let ends = trimmed.ends_with(':');
match (starts, ends) {
(true, true) => CellAlignment::Center,
(false, true) => CellAlignment::Right,
_ => CellAlignment::Left,
}
}).collect()
}
```
Edge cases:
- Empty cells (`| | |`) → empty strings in the returned Vec, comfy-table handles.
- Column count mismatch (header has 3 cells, data row has 2) → comfy-table's behavior: pads or truncates. Test coverage will verify.
- Escaped pipes (`\|`) in cell content — GFM spec supports; **defer to Phase 2.1 follow-up if needed**. Initial implementation splits on raw `|`.
### Table rendering
```rust
use comfy_table::{Table, CellAlignment, presets::UTF8_FULL, ContentArrangement};
fn render_table(
&self,
header: Vec<String>,
alignments: Vec<CellAlignment>,
rows: Vec<Vec<String>>,
) -> String {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_content_arrangement(ContentArrangement::Dynamic);
if let Some(width) = self.wrap_width {
table.set_width(width);
}
// Header cells: inline-rendered + heading style (bold + heading color)
let styled_header: Vec<String> = header.iter()
.map(|c| apply_bold(&apply_inline(c, &self.styles), self.styles.heading.0))
.collect();
table.set_header(styled_header);
// Per-column alignment
for (i, align) in alignments.iter().enumerate() {
if let Some(col) = table.column_mut(i) {
col.set_cell_alignment(*align);
}
}
// Data rows: inline-rendered only
for row in rows {
let styled_row: Vec<String> = row.iter()
.map(|c| apply_inline(c, &self.styles))
.collect();
table.add_row(styled_row);
}
// Border color: apply table_border to all box-drawing chars via ANSI wrapping.
// comfy-table's styling API — inspect final rendered output and colorize border chars,
// OR use comfy-table's built-in styling if it supports per-component color.
// Investigate during Phase 2.4 implementation.
format!("{table}")
}
```
**Border color note**: comfy-table has border styling but it may not expose direct per-char color control. Two options:
1. Post-process the rendered string with a regex that colorizes box-drawing chars (`[─│┼┌┐└┘├┤┬┴]`).
2. Use comfy-table's `style()` API if it supports arbitrary ANSI.
Confirm during Phase 2.4 implementation — worst case is regex post-process, which is simple.
### `render_line` (immutable) behavior for partial table rows
`render_line` is called on the incomplete in-progress line during streaming. If the partial buffer looks like `| foo | ba`, it's mid-row and immutable — can't add to state.
Behavior: `render_line` sees `LineKind::TableRow` or the pattern and just renders raw markdown (since it can't buffer). The user sees `| foo | ba` briefly, then it disappears when the complete row arrives via `render_line_mut` (silent accumulation) and eventually the rendered table appears. Consistent with the "silent accumulation" decision.
### Line wrapping with hanging indent
Phase 1's `render_bullet`/`render_numbered`/`render_task`/`render_blockquote` produce a single line each and don't wrap long content. When `wrap_width` is set, long items overflow past the wrap column. This phase fixes that by adding **hanging-indent wrapping** using `textwrap` (already a dependency).
**Desired output:**
```
• text that
wraps and
wraps
1. text that
wraps and
wraps
[ ] task text
that wraps
│ blockquote line
│ that continues
```
**Design:**
Each block renderer computes a prefix width, applies wrapping to the content with `textwrap::Options::subsequent_indent(prefix_width_spaces)`, then styles each wrapped line with the appropriate prefix on line 1 and continuation-indent on later lines.
Critical subtlety: `textwrap` computes width by **byte length**, not visual width. We must wrap the **plain text content** (before applying inline ANSI codes), then apply `apply_inline` per wrapped line. Otherwise ANSI escape bytes distort the wrap column calculation.
Sketch:
```rust
fn render_bullet(&self, line: &str) -> String {
let (leading, content) = split_leading_indent(line); // handles nested lists
let content_after_marker = &content[content.find(' ').unwrap() + 1..]; // strip "- "
let Some(wrap_width) = self.wrap_width else {
// No wrap → single line (current Phase 1 behavior)
return format!("{leading}{bullet}{}", apply_inline(content_after_marker, &self.styles));
};
let bullet_visible = ""; // 2 columns
let subseq_indent = " "; // 2 spaces to align under text
let effective_width = (wrap_width as usize).saturating_sub(leading.len() + bullet_visible.len());
let wrapped = textwrap::wrap(content_after_marker, textwrap::Options::new(effective_width));
let styled_bullet = ansi_wrap(bullet_visible, self.styles.list_bullet);
let mut out = String::new();
for (i, wline) in wrapped.iter().enumerate() {
if i == 0 {
out.push_str(&format!("{leading}{styled_bullet}{}", apply_inline(wline, &self.styles)));
} else {
out.push_str(&format!("\n{leading}{subseq_indent}{}", apply_inline(wline, &self.styles)));
}
}
out
}
```
Same pattern for `render_numbered` (subsequent indent width = digits + `. ` = variable), `render_task` (subsequent indent = 4 spaces for `[ ] `), and `render_blockquote` (subsequent indent = styled `│ ` prefix, same styling as first line).
**Interaction with existing `wrap_line`:** The current `wrap_line` (markdown.rs:192-198) is used for code lines and paragraphs. It sets `initial_indent` but not `subsequent_indent`, so paragraphs already wrap without hanging indent — that's correct (paragraphs should wrap flush-left). Only list/blockquote block renderers need the new hanging-indent path; leave `wrap_line` alone.
**Nested lists:** the existing `leading` whitespace preservation from Phase 1 continues to work — subsequent-indent gets prepended AFTER the leading, so a nested list item wraps correctly under its own bullet.
**Headings:** intentionally NOT wrapped with hanging indent. If a heading is longer than wrap width, it wraps flush-left (via existing `wrap_line`). Headings are usually short; hanging indent under `##` would look odd.
**`wrap_width = None`:** all renderers skip wrapping entirely and emit a single line, matching current Phase 1 behavior. Users who want wrapping set the `wrap: auto` config.
### `finalize()` method
New method on `MarkdownRender`:
```rust
pub fn finalize(&mut self) -> String {
match self.table_state.take() {
None => String::new(),
Some(TableState::PendingHeader(line)) => self.render_as_paragraph(&line),
Some(TableState::Active { header, alignments, rows }) => {
self.render_table(header, alignments, rows)
}
}
}
```
Called by:
1. **`stream.rs`** at `SseEvent::Done` — before `break 'outer`, emit `render.finalize()` output.
2. **`app_config.rs::print_markdown`** — after `markdown_render.render(text)`, append `finalize()` output.
3. **`session.rs::Session::render`** — after `render.render(text)`, append `finalize()` output.
Each is a single-line addition.
## Consumers Touched (Phase 2)
| File | Change |
|---|---|
| `src/render/markdown.rs` | Add table state, detection, rendering (~250 lines) |
| `src/render/stream.rs` | Call `render.finalize()` on SseEvent::Done (~2 lines) |
| `src/config/app_config.rs` | Call `finalize()` after `render()` in `print_markdown` (~1 line) |
| `src/config/session.rs` | Call `finalize()` after `render()` in `Session::render` (~1 line) |
| `Cargo.toml` | Add `comfy-table` dependency |
## Phase 2 Implementation
### Phase 2.1 — Add `comfy-table` + `table_border` style
- [x] Add `comfy-table = { version = "7.2.2", features = ["custom_styling"] }` to `Cargo.toml`
- [x] Add `table_border: Color` field to `MarkdownStyles`
- [x] Resolve in `MarkdownStyles::from_theme` from `punctuation.definition.table.markdown` with fallback chain
- [x] Handle `theme.is_none()` → default color
- [x] Test: `table_border` resolves correctly with built-in theme
- [x] Test: fallback chain works with minimal theme
**Commit:** `feat(render): add comfy-table dependency and table border style`
### Phase 2.2 — Table row detection
- [x] Add `TABLE_ROW` and `TABLE_SEPARATOR` regexes
- [x] Add `TableRow` and `TableSeparator` variants to `LineKind`
- [x] Extend `detect_line_kind` (separator check before row check)
- [x] Test: header row (`| a | b |`) → `TableRow`
- [x] Test: separator (`|---|---|`) → `TableSeparator`
- [x] Test: separator with alignment (`|:--|--:|:-:|`) → `TableSeparator`
- [x] Test: non-table pipe line in prose (`use \`a | b\``) → `Paragraph` (only if it doesn't match `^\s*\|.*\|\s*$` — verify)
- [x] Test: empty cells (`| | |`) → `TableRow`
**Commit:** `feat(render): detect markdown table rows and separators`
### Phase 2.3 — Cell + alignment parsing
- [x] Add `parse_table_row(line) -> Vec<String>`
- [x] Add `parse_alignments(separator_row) -> Vec<CellAlignment>`
- [x] Test: `| a | b | c |``["a", "b", "c"]`
- [x] Test: empty cells `| a | | c |``["a", "", "c"]`
- [x] Test: alignments `|:---|---:|:---:|---|``[Left, Right, Center, Left]`
- [x] Test: leading/trailing whitespace stripped
**Commit:** `feat(render): parse table cells and column alignments`
### Phase 2.4 — Table rendering via comfy-table
- [x] Add `TableState` enum (PendingHeader / Active)
- [x] Add `table_state: Option<TableState>` field to `MarkdownRender`, init `None`
- [x] Implement `render_table(header, alignments, rows) -> String`
- [x] Apply `apply_inline` to each cell; apply bold + heading color to header cells
- [x] Set alignment per column
- [x] Set width from `wrap_width` if present
- [x] Investigate comfy-table border color API; if insufficient, post-process box-drawing chars with regex to apply `table_border` color
- [x] Test: 3x3 table with default alignment
- [x] Test: alignment specifiers applied correctly
- [x] Test: header rendered with bold + heading color
- [x] Test: borders rendered with `table_border` color
- [x] Test: cell containing inline markdown (`**bold**`, `` `code` ``, `[link](url)`) — width computed correctly (ANSI stripped)
- [x] Test: wide chars / emoji in cells
**Commit:** `feat(render): render markdown tables with comfy-table`
### Phase 2.5 — State machine + finalize
- [x] Implement `handle_table_state(line, kind) -> Option<String>` for state transitions
- [x] Wire into `render_line_mut` BEFORE existing code/raw/rich branch
- [x] Handle all 6 transitions from the state diagram above
- [x] Implement `pub fn finalize(&mut self) -> String`
- [x] Add `finalize()` call in `src/render/stream.rs` at `SseEvent::Done` (write output)
- [x] Add `finalize()` call in `src/config/app_config.rs::print_markdown` after `render()`
- [x] Add `finalize()` call in `src/config/session.rs::Session::render` after `render()`
- [x] Test: table followed by paragraph → rendered table + paragraph
- [x] Test: table at end of input (no trailing non-table line) → `finalize()` emits rendered table
- [x] Test: `|...|` line NOT followed by separator → both flushed as paragraphs
- [x] Test: multiple tables in one input
- [x] Test: `render_line` on partial `| foo | ba` (immutable) → raw text (no state mutation)
**Commit:** `feat(render): wire table state machine and finalize hook`
### Phase 2.6 — Hanging-indent line wrapping for lists and blockquotes
- [x] Add `wrap_with_hanging_indent(content, prefix_width, wrap_width) -> Vec<String>` helper (uses `textwrap` on plain content, callers apply inline styling per line)
- [x] Refactor `render_bullet` to compute prefix width (`• ` = 2), wrap, apply inline per line, prepend styled bullet + subsequent 2-space indent
- [x] Refactor `render_numbered` to compute prefix width from digit count + `. `, wrap, apply inline per line, prepend styled number + subsequent variable-width indent
- [x] Refactor `render_task` to compute prefix width (`[ ] ` = 4), wrap, apply inline per line, prepend styled checkbox + subsequent 4-space indent
- [x] Refactor `render_blockquote` to wrap, apply inline per line, prepend styled `│ ` on every line (both initial and subsequent)
- [x] `wrap_width = None` path: skip wrapping, emit single line (matches Phase 1)
- [x] Preserve leading whitespace (nested list indent) — subseq indent goes AFTER leading
- [x] Test: bullet with content wider than wrap_width → hanging indent under text
- [x] Test: numbered list with 2+ digit numbers (`10. `, `100. `) → subseq indent matches digit width
- [x] Test: task item wraps with 4-space subseq indent
- [x] Test: blockquote wraps with `│ ` continuation prefix (styled same as first line)
- [x] Test: nested bullet (` - inner text that wraps`) → nested indent + hanging indent both applied
- [x] Test: content with inline markdown that wraps mid-span — wrap boundary respects word breaks, not ANSI escapes
- [x] Test: `wrap_width = None` → no wrapping (single line, current behavior)
**Commit:** `feat(render): hanging-indent line wrapping for lists and blockquotes`
### Phase 2.7 — Integration + edge cases
- [x] Test: markdown with mixed content (paragraphs + headings + tables + lists)
- [x] Test: `raw_markdown: true` bypasses table rendering (renders as raw pipe rows via syntect grammar)
- [x] Test: `theme.is_none()` → tables still render (uncolored) via comfy-table
- [x] Test: user's custom theme colors apply to borders
- [x] Test: column count mismatch (header has 3, row has 2) — verify comfy-table behavior; document expected output
- [ ] Manual REPL test: stream a response with tables, verify silent accumulation → rendered flush *(deferred — requires interactive terminal)*
- [ ] Manual REPL test: `.set raw_markdown true` reverts tables to raw *(deferred — requires interactive terminal)*
- [x] Update `.sisyphus/plans/rich-markdown-renderer.md` progress log noting Phase 2 completion + commit SHA
- [x] `cargo check` clean
- [x] `cargo test` all pass
**Commit:** `test(render): comprehensive table rendering coverage`
## Success Criteria (Phase 2)
- [ ] Standard GFM tables render with box-drawing chars
- [ ] Alignment specifiers (`:---`, `---:`, `:---:`) respected
- [ ] Inline markdown inside cells (`**bold**`, code, links) renders correctly
- [ ] Wide chars / emoji don't misalign columns (comfy-table's `ansi_strip().width()` verified working)
- [ ] Border color from user's syntect theme
- [ ] Header row is bold + heading color
- [ ] Silent accumulation during streaming (no flashing raw→rendered transitions)
- [ ] Tables at end of stream/input flush via `finalize()`
- [ ] `|...|` lines without separator NOT rendered as tables
- [ ] Table cell content wraps within column boundary (via `comfy-table`'s `ContentArrangement::Dynamic`)
- [ ] Bullet list items wrap with 2-space hanging indent under text
- [ ] Numbered list items wrap with digit-width hanging indent
- [ ] Task list items wrap with 4-space hanging indent
- [ ] Blockquotes wrap with `│ ` continuation prefix on every line
- [ ] `wrap_width = None` disables wrapping (matches Phase 1 behavior)
- [ ] `raw_markdown: true` bypasses table rendering AND list-wrap changes (raw markdown throughout)
- [ ] `theme.is_none()` still produces functional (uncolored) tables and wrapped lists
- [ ] All existing tests pass unchanged
- [ ] `cargo check` clean
- [ ] `cargo test` all pass
## Progress Log
Append-only. One entry per commit or session.
### 2026-07-22 — Planning complete
- Verified post-Phase-1 state via explore agent (MarkdownRender struct, LineKind enum, apply_inline pipeline, streaming buffer mechanics)
- Surveyed Rust table libraries via librarian agent → chose `comfy-table 7.2.2` (only lib with correct ANSI-in-cells width handling + active maintenance + arbitrary border colors)
- Resolved 7 design decisions (library, streaming behavior, detection lookahead, border color, header styling, alignment parsing, cell inline rendering)
- Acknowledged 2 justified deviations from Phase 1 principles (multi-line state, 3 small callsite changes for finalize hook)
- Added Phase 2.6 (hanging-indent wrapping for lists and blockquotes) — user-requested addition; touches Phase 1 block renderers (render_bullet/render_numbered/render_task/render_blockquote) but reuses existing `textwrap` dep. Tables get wrapping for free via `comfy-table`'s `ContentArrangement::Dynamic`.
- Wrote this plan file
- Next: hand to Momus for review before starting Phase 2.1
### 2026-07-22 — Phase 2.1 complete (commit `fcc4a1d`)
- Added `comfy-table 7.2.2` with `custom_styling` feature to `Cargo.toml`; slotted alphabetically between `clap` and `dirs`.
- Added `table_border: Color` field to `MarkdownStyles`; resolved in `from_theme` via `punctuation.definition.table.markdown``punctuation``meta.separator` fallback chain; `none()` sets `Color::Reset`.
- Extended the three existing `MarkdownStyles` tests with `table_border` assertions (dark theme resolves ≠ Reset, minimal-root-scope theme falls back to `punctuation` color `rgb(0x77, 0x77, 0x77)`, no-theme → `Color::Reset`).
- Marked field `#[allow(dead_code)]` — will be removed in Phase 2.4 when `render_table` consumes it.
- `cargo check` clean, `cargo test` all 1207 pass.
### 2026-07-22 — Phase 2.2 complete (commit `7671d28`)
- Added `TABLE_ROW_RE` and `TABLE_SEPARATOR_RE` regexes. Separator uses `-+` (one or more dashes) instead of the plan's `{3,}` to accept the plan's own test case `|:--|--:|:-:|`; GFM spec doesn't mandate a minimum, so more lenient is safer.
- Added `TableRow` / `TableSeparator` variants to `LineKind`; extended `detect_line_kind` (separator checked before row).
- `render_markdown_line` handles both variants as `apply_inline` (paragraph-equivalent) — they'll be intercepted by the state machine in Phase 2.5 before reaching this fallback.
- 4 new tests covering row, separator (three alignment shapes), non-table pipes in prose, and separator-vs-row precedence.
### 2026-07-22 — Phase 2.3 complete (commit `c062f34`)
- Added `parse_table_row(line)` and `parse_alignments(separator_row)` free functions.
- Imported `comfy_table::CellAlignment` at module level (also used in Phase 2.4).
- Both functions marked `#[allow(dead_code)]` — consumed by state machine in Phase 2.5.
- 6 tests: cell splitting, empty cells, whitespace trimming, colon-based alignment mapping (long dashes, short dashes, default-to-left).
### 2026-07-22 — Phase 2.4 complete (commit `cdfaa0f`)
- Added `TableState` enum (`PendingHeader(String)` / `Active { header, alignments, rows }`) and `table_state: Option<TableState>` field on `MarkdownRender`.
- Implemented `MarkdownRender::render_table` using `comfy-table`'s `UTF8_FULL` preset + `ContentArrangement::Dynamic`; sets `wrap_width` on the table when present; per-column alignment via `column_mut(i).set_cell_alignment`.
- Header cells: `apply_inline` then wrapped in `.with(heading_color).bold()`. Data cells: `apply_inline` only.
- Border coloring: `colorize_box_chars` helper post-processes the rendered string. It extracts SGR prefix/suffix from a probe styled character, walks the input once, and wraps consecutive box-drawing runs (`\u{2500}..=\u{257F}`) with the SGR pair.
- `#[allow(dead_code)]` on `TableState`, `table_state`, `render_table`, `colorize_box_chars`, and (re-added) `table_border` — cleared in Phase 2.5 once the state machine wires everything in.
- 8 new tests: border colorization (with/without borders), header bold, inline markdown in cells, alignment specifiers, wide chars/emoji, border color at output start, plus a 3x3 default-alignment sanity test.
### 2026-07-22 — Phase 2.5 complete (commit `bf06d5e`)
- Added `TableAction` enum (`Consumed(String)` / `FlushAndContinue(String)` / `Passthrough`) — replaces the plan's ambiguous `Option<String>` return with an explicit three-way decision.
- Implemented `MarkdownRender::handle_table_state` covering all 7 transitions from the state diagram (including code-block entry as an implicit flush trigger).
- Implemented `MarkdownRender::render_as_paragraph` helper for false-positive header flushes.
- Implemented `pub fn finalize(&mut self) -> String`.
- `render_line_mut` runs the state machine before the code/raw/rich dispatch. `raw_markdown: true` bypasses the state machine entirely (raw mode preserves user-supplied markdown untouched). Code block entry (`is_code`) maps to `LineKind::Paragraph` for state-machine purposes, forcing a flush.
- Wired `finalize()` into three call sites:
- `src/render/stream.rs` at `SseEvent::Done` — queues a trailing newline + flushed output via crossterm `queue!/style::Print` before break.
- `src/config/app_config.rs::print_markdown` — appends flush output before `println!`.
- `src/config/session.rs::Session::render` — flushes after both System and Assistant message rendering (per-message finalize prevents cross-message state bleed).
- Dropped `#[allow(dead_code)]` from `TableState`, `table_state`, `render_table`, `colorize_box_chars`, `parse_table_row`, `parse_alignments`, and `table_border` — all now live in the binary.
- 9 new tests: full-table streaming, deferred silent accumulation, `finalize` for active/pending/empty state, `|...|`-without-separator flush, multiple tables in one input, `render_line` immutability, raw-mode bypass.
### 2026-07-22 — Phase 2.6 complete (commit `d790782`)
- Added `wrap_plain_content(content, effective_width) -> Vec<String>` helper (thin `textwrap::wrap` wrapper that clamps width to ≥1).
- Added `kind_pre_wraps(kind) -> bool` helper (returns true for bullet/numbered/task/blockquote).
- Threaded `wrap_width: Option<u16>` through `render_markdown_line` and all four block renderers.
- `wrap_width = None` short-circuits back to Phase 1 single-line behavior.
- `wrap_width = Some(w)` wraps plain content (pre-inline-styling) at `w - (leading + prefix_width)`, then applies `apply_inline` per wrapped chunk.
- Prefix widths: `render_bullet` = 2 (`• `), `render_task` = 4 (`[ ] `), `render_numbered` = digit_count + 2 (`. `), `render_blockquote` = 2 (`│ `).
- `render_blockquote` prepends the styled `│ ` on **every** wrapped line (not just the first); the other three prepend the marker on line 1 and a spaces-only subsequent indent on continuation lines.
- Leading whitespace (nested-list indent) is emitted BEFORE the prefix on every wrapped line, preserving nested-list appearance.
- `render_rich_markdown_line` skips `wrap_line` when `kind_pre_wraps(kind)` is true, avoiding a second unwanted wrap pass over already-styled content.
- Updated all 17 existing test call sites of `render_markdown_line` (via ast-grep) to pass `None` — Phase 1 behavior preserved end-to-end.
- 8 new tests: bullet 2-space indent, numbered 4-space (`42. `) and 5-space (`100. `) indent, task 4-space indent, blockquote pipe-on-every-line, nested-bullet leading indent, `None` single-line short-circuit, inline markdown intact after wrap.
### 2026-07-22 — Phase 2.7 complete (commit `e82e5ab`)
- 4 integration/edge-case tests: mixed-content document (heading + paragraph + list + blockquote + table + trailing prose), table renders without theme, table borders pick up custom theme color, column-count mismatch tolerated by comfy-table.
- Full test suite: 1246 pass, 0 fail. `cargo check` clean.
- Manual REPL verification deferred (requires interactive terminal); test coverage validates rendering pipeline end-to-end.
- Phase 2 complete.
+265
View File
@@ -0,0 +1,265 @@
# Rich Markdown Renderer for the REPL
**Status:** Planning complete, awaiting Momus review before implementation.
**Owner:** Coyote maintainer
**Estimated effort:** Phase 1 = 4-5 days, Phase 2 (tables) = +1-2 days
**Related flag:** `raw_markdown` (already plumbed; see commit history for the plumbing PR)
---
## Goal
Replace Coyote's current syntect-only markdown rendering with a rich renderer that transforms markdown syntax into styled terminal output (headings become colored + bold text, `**bold**` becomes actual bold, backticks strip and stylize, blockquotes get a `│` prefix, etc.), matching glamour's structural output while preserving the user's existing syntect `.tmTheme` colors.
The current renderer just applies syntect's markdown grammar for syntax highlighting — the markdown syntax characters (`#`, `**`, `` ` ``) stay in the output, just colored. Users get raw markdown with color, not rendered markdown. The new renderer actually transforms the markdown into styled output like glamour (github.com/charmbracelet/glamour) does.
## Non-Goals
- **Not replacing the renderer's public API.** `MarkdownRender::init`, `render`, `render_line`, and `RenderOptions` all keep their existing signatures. Callers (`stream.rs`, `session.rs`, `app_config.rs::print_markdown`, `request_context.rs::session_info`) do not change.
- **Not changing streaming architecture.** `stream.rs` still calls `render()` on complete lines and `render_line()` on the incomplete tail. New renderer must fit this line-by-line contract.
- **Not touching code block rendering.** Fenced code blocks (` ```lang ... ``` `) continue to route to syntect language-specific highlighting via `find_syntax_by_token`. The new renderer only affects markdown syntax rendering, never code content.
- **Not adding new dependencies.** All work uses existing `syntect`, `fancy-regex`, `crossterm`, `textwrap`.
- **Not shipping tables in Phase 1.** Tables require multi-line buffering, which conflicts with the stateless streaming model. Table rows render as raw `| col | col |` until Phase 2.
- **Not implementing OSC 8 hyperlink fallback logic.** Emit OSC 8 codes unconditionally + always show URL visibly. Terminals that don't support OSC 8 strip the codes and see plain "text URL" text.
## Design Principles
1. **Colors from user theme, layout from glamour.** Every construct extracts its color from the user's syntect theme via scope lookup with fallback chains. The structural layout (prefixes, indents, borders, box-drawing) matches glamour's default dark style.
2. **`raw_markdown: true` = current behavior byte-identical.** The existing syntect-on-markdown-grammar path is preserved as the "raw" branch and reachable via config/CLI/REPL. Zero regression risk for users who want the old behavior.
3. **Preserve line-by-line rendering.** No state beyond the existing `LineType` code-block tracker. Stateless per-line rendering means the streaming's `render_line` for partial buffer works identically to the mutating `render_line_mut` for complete lines.
4. **Regex-based inline parsing, not pulldown-cmark.** A full markdown parser needs the complete document to disambiguate. Regexes match balanced spans and gracefully leave unclosed spans as raw text — exactly right for streaming's mid-token partial-line rendering.
5. **Only `src/render/markdown.rs` changes.** Scope containment: the entire implementation lives in one file. No touches to `stream.rs`, `mod.rs`, `session.rs`, `app_config.rs`, `request_context.rs`.
## Resolved Design Decisions
Recorded here so future sessions don't re-litigate them:
1. **Tables:** deferred to Phase 2. Phase 1 leaves table rows as raw markdown.
2. **H2-H6 hash prefixes:** matched to glamour — keep `##`, `###`, `####`, `#####`, `######` visible in the heading color as a level indicator. H1 gets padded ` text ` treatment.
3. **Link rendering:** OSC 8 hyperlink codes wrapping visible `{text} {url}` — modern terminals show a clickable link, older terminals show plain styled text. Matches glamour exactly. Users on broken terminals can fall back to `.set raw_markdown true`.
## Architecture
### Data structures (added to `MarkdownRender`)
```rust
struct MarkdownStyles {
heading: (Color, bool /* force_bold */),
bold: Color,
italic: Color,
inline_code_fg: Color,
inline_code_bg: Option<Color>,
blockquote: Color,
list_bullet: Color,
link_text: Color,
link_url: Color,
strikethrough: Color,
hrule: Color,
}
```
Populated once in `MarkdownRender::init` via a new `resolve_scope_style(theme, primary_scope, fallbacks)` helper that generalizes the existing `get_code_color()` pattern (markdown.rs:299).
When `options.theme.is_none()`, all styles collapse to defaults (raw text output with no colors — matches current behavior).
### Line-type detection
Extended `check_line` returns a new `LineKind` enum (only for non-code lines):
| Regex | LineKind |
|---|---|
| `^\s*(#{1,6}) +.+` | Heading(level) |
| `^\s*> ?.*` | Blockquote |
| `^(\s*)- \[[ xX]\] +.+` | TaskItem(checked) |
| `^(\s*)[-*+] +.+` | BulletItem |
| `^(\s*)\d+\. +.+` | NumberedItem |
| `^\s*(-{3,}|_{3,}|\*{3,})\s*$` | HorizontalRule |
| `^\s*\|.*\|\s*$` | (Phase 2: TableRow) — treated as paragraph for now |
| default | Paragraph |
**Stateless:** line-type detection carries no state beyond the existing `prev_line_type`/`code_syntax` fields for code block tracking. Streaming's partial-line `render_line` works identically to complete-line `render_line_mut`.
### Block-level rendering
Each `LineKind` triggers a block transformation that strips syntax markers and applies structural styling. All block types then run their remaining text content through the inline pipeline.
| LineKind | Transformation |
|---|---|
| `Heading(1)` | Prefix ` `, suffix ` ` (single spaces), apply bold + heading color to entire line |
| `Heading(2..=6)` | Keep visible `##`/`###`/etc. prefix, apply bold + heading color |
| `Blockquote` | Replace `> ` with `│ ` (styled blockquote color); apply blockquote color to remaining content |
| `BulletItem` | Replace `-`/`*`/`+` with `•` (styled list_bullet color); preserve leading whitespace for nesting |
| `NumberedItem` | Preserve number, style the `.` in list_bullet color |
| `TaskItem(false)` | Replace `[ ]` with `[ ]` styled in list_bullet color |
| `TaskItem(true)` | Replace `[x]` with `[✓]` styled |
| `HorizontalRule` | Emit `────────` (8-char box-drawing) styled with hrule color (typically dim/gray) |
| `Paragraph` | No block transform, inline pass only |
### Inline rendering (regex pipeline, applied in order)
Order matters — inline code first prevents re-parsing code content as bold/italic:
1. **Inline code** (`` `text` ``) — regex `` `([^`\n]+)` ``, strip backticks, apply `inline_code_fg` + optional `inline_code_bg`.
2. **Images** (`![alt](url)`) — regex `!\[([^\]]*)\]\(([^)]+)\)`, emit `Image: {alt} → {url}` styled with `link_url`. Wrap in OSC 8 hyperlink codes.
3. **Links** (`[text](url)`) — regex `\[([^\]]+)\]\(([^)]+)\)`, emit `{text} {url}` with `link_text` on the label and `link_url` on the URL. Wrap in OSC 8 hyperlink codes.
4. **Bold** (`**text**` or `__text__`) — regex `\*\*([^*\n]+)\*\*` and `__([^_\n]+)__`, strip markers, apply bold ANSI + `bold` color.
5. **Italic** (`*text*` or `_text_`) — regex `(?<![*\w])\*([^*\n]+)\*(?!\*)` and `(?<![_\w])_([^_\n]+)_(?!_)` — lookbehind/lookahead prevents word-internal `_` from matching (e.g., `some_var_name`). `fancy-regex` supports lookbehind.
6. **Strikethrough** (`~~text~~`) — regex `~~([^~\n]+)~~`, strip markers, apply ANSI strikethrough (`\x1b[9m`).
**Partial-span handling for streaming:** regexes only match balanced spans. Unclosed spans (`**bold` with no closing) stay raw. When the closing marker arrives on the next token, the complete-line pass renders the full span correctly.
### OSC 8 hyperlinks
```
\x1b]8;;{url}\x1b\\{visible_text}\x1b]8;;\x1b\\
```
Emit unconditionally around links and images. Unsupported terminals strip the codes and see plain visible text. Zero degradation.
### Branching in `highlight_line`
```rust
fn highlight_line(&self, line: &str, syntax: &SyntaxReference, is_code: bool) -> String {
if is_code {
// unchanged — code block content via language-specific syntect
self.highlight_code_syntect(line, syntax)
} else if self.options.raw_markdown {
// preserved current behavior: syntect on markdown grammar
self.highlight_markdown_syntect(line, &self.md_syntax)
} else {
// new rich rendering path
self.render_markdown_line(line)
}
}
```
Code blocks route to syntect regardless of `raw_markdown` — the flag only affects markdown syntax rendering.
## Consumers Verified
Complete map of `MarkdownRender` consumers (from explore agent research). All continue to work without modification because the public API is unchanged:
1. `src/render/mod.rs:16-33``render_stream()` (streaming path via `markdown_stream()`)
2. `src/render/stream.rs:67-171``markdown_stream_inner()` calls `render.render(head)` and `render.render_line(&buffer)`
3. `src/config/app_config.rs:420-429``print_markdown()` (CLI one-shot)
4. `src/config/request_context.rs:1706-1723``session_info()` (`.info` REPL command)
5. `src/config/session.rs:278-396``Session::render()` (per assistant message)
6. `src/render/markdown.rs:311-397` — existing tests
## Phase 1 Implementation
### Phase 1.1 — Scope lookup helper + precomputed styles
- [x] Add `resolve_scope_style(theme, primary, fallbacks)` helper (generalizes `get_code_color()`)
- [x] Add `MarkdownStyles` struct + populate in `MarkdownRender::init` for all 10 constructs
- [x] Handle `theme.is_none()` gracefully (all styles = defaults)
- [x] Test: verify each style resolves correctly with the built-in dark theme
- [x] Test: verify each style falls back correctly with a minimal theme that only defines root scopes
**Commit:** `feat(render): precompute markdown scope styles for rich rendering`
### Phase 1.2 — Line-type detection
- [x] Add `LineKind` enum + `detect_line_kind()` function
- [x] Wire into `check_line` — return `LineKind` alongside existing `LineType`
- [x] Test each pattern in isolation (heading, blockquote, bullets, numbered, task, hrule, paragraph)
- [x] Test edge cases: `## ` vs `##text` (no space, not a heading), indented list items, empty blockquote
**Commit:** `feat(render): detect markdown block-level line types`
### Phase 1.3 — Inline rendering pipeline
- [x] Add regex constants (LazyLock) for each inline construct
- [x] Add `apply_inline(text: &str, styles: &MarkdownStyles) -> String` that runs the pipeline in order
- [x] Test each construct in isolation
- [x] Test order-dependence: `**foo `bar` baz**` — bold wraps inline code correctly
- [x] Test partial spans stay raw: `**unclosed``**unclosed`
- [x] Test italic doesn't false-positive: `some_var_name`, `a * b * c` (math-like expression)
- [x] Test OSC 8 emission for links and images
**Commit:** `feat(render): rich inline markdown rendering (bold, italic, code, links)`
### Phase 1.4 — Block-level rendering
- [x] Add `render_markdown_line(line)` that dispatches on `LineKind`
- [x] Implement each block transform (heading, blockquote, bullet, numbered, task, hrule, paragraph)
- [x] After block transform, always run `apply_inline` on the content
- [x] Test each block type with inline styling nested inside (bold in heading, code in list item, link in blockquote)
**Commit:** `feat(render): rich block-level markdown rendering (headings, quotes, lists, hr)`
### Phase 1.5 — Wire into `highlight_line` with `raw_markdown` branch
- [x] Refactor `highlight_line` to branch on `options.raw_markdown`
- [x] Remove `#[allow(dead_code)]` from `RenderOptions::raw_markdown`
- [x] Verify all existing tests pass with `raw_markdown: true` (byte-identical output)
- [ ] Manual REPL test: send a message with a mix of constructs, verify output matches expectations
- [ ] Manual streaming test: verify no flashing, partial spans render smoothly
**Commit:** `feat(render): activate rich markdown renderer as default`
### Phase 1.6 — Test coverage
- [x] Heading levels 1-6 (transforms + styling)
- [x] Bold, italic, inline code, strikethrough
- [x] Inline code strips backticks
- [x] `some_var_name` NOT italicized
- [x] `a * b * c` math not italicized
- [x] Blockquote `│ ` prefix
- [x] Bullet `•` transformation
- [x] Numbered list preservation
- [x] Task items `[ ]` / `[✓]`
- [x] Horizontal rule
- [x] Links: styled text + URL, OSC 8 codes present
- [x] Images: `Image: {alt} → {url}` format, OSC 8 codes present
- [x] Nested inline in blocks (bold in heading, code in list)
- [x] Partial spans in `render_line`
- [x] `theme=None` degrades to raw stripped text (no colors, but syntax stripped)
- [x] `raw_markdown=true` matches current behavior byte-for-byte
**Commit:** `test(render): comprehensive coverage for rich markdown renderer`
## Phase 2 (Follow-up PR) — Tables
Deferred scope. Rough sketch:
- Add `Option<TableBuffer>` field to `MarkdownRender`
- On table row detection, accumulate rows in buffer (emit raw markdown for now to keep streaming visible)
- On non-table line (or blank), flush the buffer: compute column widths, render with box-drawing chars, emit
- Handle streaming: use cursor-erase to replace raw rows with rendered table when buffer flushes
- Test coverage: single-column, multi-column, alignment specifiers (`:---`, `---:`, `:---:`), empty cells, long content wrapping
## Success Criteria (Phase 1)
- [x] All existing tests pass with `raw_markdown: true`
- [x] All new tests pass with `raw_markdown: false`
- [x] `cargo check` clean
- [x] `cargo test` all pass
- [ ] Manual REPL test: streaming looks smooth (no flashing, no visible partial spans getting re-rendered)
- [ ] Manual REPL test: `.set raw_markdown true` reverts to current behavior
- [ ] Manual test: user's custom theme colors apply to headings/bold/etc. (not just default)
## Progress Log
Append-only. One entry per commit or session.
### 2026-07-22 — Planning complete
- Scoped implementation via research (glamour source, syntect scope conventions, current renderer consumers)
- Resolved 3 open design questions (tables deferred, glamour hash prefixes matched, OSC 8 with fallback)
- Wrote this plan file
- Next: hand to Momus for review before starting Phase 1.1
### 2026-07-22 — Phase 1.1 complete (`d2940a8`)
- Added `resolve_scope_style` helper + `MarkdownStyles` struct with 10 constructs, precomputed once in `MarkdownRender::init`; new struct is `#[allow(dead_code)]` until Phase 1.5 wires it in. 6 new tests cover primary/fallback/default paths, `theme.is_none()`, built-in dark theme, and a minimal-root-scopes theme.
### 2026-07-22 — Phase 1.2 complete (`f40ba4c`)
- Added `LineKind` enum (Heading/Blockquote/TaskItem/BulletItem/NumberedItem/HorizontalRule/Paragraph) and `detect_line_kind()` using `fancy_regex` for the 6 block patterns. Wired into `check_line` — signature now returns `(LineType, LineKind, Option<SyntaxReference>, bool)`; callers ignore `LineKind` with `_` until Phase 1.4. 8 new tests cover each pattern plus edge cases (`##notheading`, `-nospace`, `--`, indented items, empty blockquote).
### 2026-07-22 — Phase 1.3 complete (`89db5b3`)
- Added inline regexes (INLINE_CODE, IMAGE, LINK, BOLD_AST, BOLD_US, ITALIC_AST, ITALIC_US, STRIKETHROUGH, CODE_PLACEHOLDER) and `apply_inline()` running the plan's 6-step pipeline. Refined italic regexes with `(?!\s)` opener + `(?<!\s)` closer to prevent `a * b * c` false-positives while still requiring the word-boundary lookbehind for `some_var_name`. Inline code is masked with `\x00C{idx}\x00` placeholders before other transforms so its content is never re-parsed. Links/images wrap in OSC 8 hyperlink codes. 15 new tests cover each construct, order-dependence, partial spans, italic false-positives, OSC 8 emission, and image-before-link ordering.
### 2026-07-22 — Phase 1.4 complete (`9890cf0`)
- Added `render_markdown_line(line, kind, styles)` dispatcher plus per-`LineKind` block renderers (`render_heading`, `render_blockquote`, `render_bullet`, `render_numbered`, `render_task`, `render_hrule`). H1 gets space-padded, H2-6 keep their `##...` prefix; blockquotes get `│ `; bullets → `•`; numbered items keep the number and style only the `.`; task items → `[ ]` / `[✓]`; hrules render as `────────`. All block variants delegate leftover content to `apply_inline`. 15 new tests cover each block type, indent preservation, and nested inline (code in bullet, link in blockquote).
### 2026-07-22 — Phase 1.5 complete (`d65d63e`)
- Wired the rich renderer into `render_line` / `render_line_mut`: code lines still route to syntect; non-code lines branch on `options.raw_markdown` (true → existing markdown-grammar syntect path, false → `render_rich_markdown_line`). Removed `#[allow(dead_code)]` from `RenderOptions::raw_markdown`, `MarkdownStyles`, `LineKind`, `detect_line_kind`, `render_markdown_line`, `apply_inline`, and the `styles` field. Updated the 3 existing tests (`no_theme`, `no_wrap_code`, `wrap_all`) to set `raw_markdown: true` — they still produce byte-identical output, proving the raw path is preserved. Manual REPL/streaming tests deferred to user.
### 2026-07-22 — Phase 1.6 complete (`b0eeba1`)
- 6 more tests filling out the coverage checklist: bold nested inside a heading, partial bold/link spans via `render_line` (streaming path), rich rendering with `theme=None` still strips syntax and emits block glyphs, rich vs raw paths diverge on the same input, and fenced code blocks still route through syntect. Total: 55 markdown tests, 1207 total tests pass, `cargo check` clean.
### 2026-07-22 — Phase 2 complete (Phase 1 successor shipped)
- Phase 2 (tables + hanging-indent list/blockquote wrapping) is complete on top of this foundation. See `rich-markdown-renderer-tables.md` for the full plan and per-sub-phase progress log. Final commits: `fcc4a1d` (2.1) → `7671d28` (2.2) → `c062f34` (2.3) → `cdfaa0f` (2.4) → `bf06d5e` (2.5) → `d790782` (2.6) → `e82e5ab` (2.7). Total markdown tests grew from 55 → 94; total test suite 1207 → 1246, all passing, `cargo check` clean.
Generated
+84
View File
@@ -80,6 +80,15 @@ dependencies = [
"rayon", "rayon",
] ]
[[package]]
name = "ansi-str"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "060de1453b69f46304b28274f382132f4e72c55637cf362920926a70d090890d"
dependencies = [
"ansitok",
]
[[package]] [[package]]
name = "ansi_colours" name = "ansi_colours"
version = "1.2.3" version = "1.2.3"
@@ -89,6 +98,16 @@ dependencies = [
"rgb", "rgb",
] ]
[[package]]
name = "ansitok"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0a8acea8c2f1c60f0a92a8cd26bf96ca97db56f10bbcab238bbe0cceba659ee"
dependencies = [
"nom 7.1.3",
"vte",
]
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "1.0.0" version = "1.0.0"
@@ -219,6 +238,12 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]] [[package]]
name = "async-compression" name = "async-compression"
version = "0.4.42" version = "0.4.42"
@@ -1016,6 +1041,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.12.0" version = "1.12.0"
@@ -1281,6 +1312,19 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "comfy-table"
version = "7.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
dependencies = [
"ansi-str",
"console",
"crossterm",
"unicode-segmentation",
"unicode-width",
]
[[package]] [[package]]
name = "compression-codecs" name = "compression-codecs"
version = "0.4.38" version = "0.4.38"
@@ -1429,6 +1473,7 @@ dependencies = [
"clap_complete", "clap_complete",
"clap_complete_nushell", "clap_complete_nushell",
"colored", "colored",
"comfy-table",
"crossterm", "crossterm",
"dirs", "dirs",
"duct", "duct",
@@ -1457,6 +1502,7 @@ dependencies = [
"path-absolutize", "path-absolutize",
"petgraph 0.7.1", "petgraph 0.7.1",
"pretty_assertions", "pretty_assertions",
"qrcode",
"rand 0.10.1", "rand 0.10.1",
"reedline", "reedline",
"reqwest 0.13.4", "reqwest 0.13.4",
@@ -3046,6 +3092,18 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -3576,6 +3634,16 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb517913cfcfb9eeda59f36020269075a152701a01606c612f547e4890be399" checksum = "9bb517913cfcfb9eeda59f36020269075a152701a01606c612f547e4890be399"
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.18" version = "0.2.18"
@@ -4418,6 +4486,21 @@ dependencies = [
"prost", "prost",
] ]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
dependencies = [
"image",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -6613,6 +6696,7 @@ version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
dependencies = [ dependencies = [
"arrayvec",
"memchr", "memchr",
] ]
+2
View File
@@ -17,6 +17,7 @@ exclude = [".github", "CONTRIBUTING.md"]
anyhow = "1.0.69" anyhow = "1.0.69"
bytes = "1.4.0" bytes = "1.4.0"
clap = { version = "4.5.40", features = ["cargo", "derive", "wrap_help"] } clap = { version = "4.5.40", features = ["cargo", "derive", "wrap_help"] }
comfy-table = { version = "7.2.2", features = ["custom_styling"] }
dirs = "6.0.0" dirs = "6.0.0"
dunce = "1.0.5" dunce = "1.0.5"
futures-util = "0.3.29" futures-util = "0.3.29"
@@ -107,6 +108,7 @@ self_update = { version = "0.44", default-features = false, features = [
"archive-zip", "archive-zip",
"compression-zip-deflate", "compression-zip-deflate",
] } ] }
qrcode = "0.14"
[dependencies.reqwest] [dependencies.reqwest]
version = "0.13.3" version = "0.13.3"
+72
View File
@@ -0,0 +1,72 @@
ARG COYOTE_VERSION
FROM docker/sandbox-templates:shell-docker
ARG COYOTE_VERSION
ARG TARGETARCH
ENV PATH="/home/agent/.cargo/bin:/home/agent/.local/bin:${PATH}"
USER root
RUN apt-get update && \
apt-get install -y --no-install-recommends \
jq curl git \
build-essential pkg-config \
cmake \
clang libclang-dev \
musl-tools \
libssl-dev \
pandoc \
bzip2 \
nano && \
rm -rf /var/lib/apt/lists/*
RUN set -euo pipefail; \
USQL_VERSION=0.21.4; \
case "${TARGETARCH}" in \
amd64) USQL_ARCH=amd64 ;; \
arm64) USQL_ARCH=arm64 ;; \
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
TMPDIR=$(mktemp -d); \
curl -fsSL --retry 3 \
"https://github.com/xo/usql/releases/download/v${USQL_VERSION}/usql_static-${USQL_VERSION}-linux-${USQL_ARCH}.tar.bz2" \
-o "$TMPDIR/usql.tar.bz2"; \
tar -xjf "$TMPDIR/usql.tar.bz2" -C "$TMPDIR"; \
install -m 0755 "$TMPDIR/usql_static" /usr/local/bin/usql; \
rm -rf "$TMPDIR"
USER 1000
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
printf '#!/bin/sh\nexec uv tool run "$@"\n' > "$HOME/.local/bin/uvx" && \
chmod +x "$HOME/.local/bin/uvx"
RUN mkdir -p /usr/local/share/npm-global/lib
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --default-toolchain stable --profile minimal && \
. "$HOME/.cargo/env" && \
cargo install --locked iwec && \
cargo install --locked ast-grep
USER root
RUN set -euo pipefail; \
case "${TARGETARCH}" in \
amd64) MUSL_TARGET=x86_64-unknown-linux-musl ;; \
arm64) MUSL_TARGET=aarch64-unknown-linux-musl ;; \
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
TMPDIR=$(mktemp -d); \
curl -fsSL --retry 3 \
"https://github.com/Dark-Alex-17/coyote/releases/download/v${COYOTE_VERSION}/coyote-${MUSL_TARGET}.tar.gz" \
-o "$TMPDIR/coyote.tar.gz"; \
tar -xzf "$TMPDIR/coyote.tar.gz" -C "$TMPDIR"; \
install -m 0755 "$TMPDIR/coyote" /home/agent/.cargo/bin/coyote; \
chown 1000:1000 /home/agent/.cargo/bin/coyote; \
rm -rf "$TMPDIR"
USER 1000
ENTRYPOINT ["coyote"]
+29 -1
View File
@@ -5,6 +5,7 @@
![Release](https://img.shields.io/github/v/release/Dark-Alex-17/coyote?color=%23c694ff) ![Release](https://img.shields.io/github/v/release/Dark-Alex-17/coyote?color=%23c694ff)
![Crate.io downloads](https://img.shields.io/crates/d/coyote-ai?label=Crate%20downloads) ![Crate.io downloads](https://img.shields.io/crates/d/coyote-ai?label=Crate%20downloads)
[![GitHub Downloads](https://img.shields.io/github/downloads/Dark-Alex-17/coyote/total.svg?label=GitHub%20downloads)](https://github.com/Dark-Alex-17/coyote/releases) [![GitHub Downloads](https://img.shields.io/github/downloads/Dark-Alex-17/coyote/total.svg?label=GitHub%20downloads)](https://github.com/Dark-Alex-17/coyote/releases)
![Docker pulls](https://img.shields.io/docker/pulls/darkalex17/coyote?label=Docker%20downloads)
Coyote is an all-in-one, batteries-included, LLM CLI tool featuring Shell Assistant, CLI & REPL Mode, RAG, AI Tools & Coyote is an all-in-one, batteries-included, LLM CLI tool featuring Shell Assistant, CLI & REPL Mode, RAG, AI Tools &
Agents, and More. Agents, and More.
@@ -38,6 +39,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
* [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation. * [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation.
* [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions. * [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions.
* [Memory](https://github.com/Dark-Alex-17/coyote/wiki/Memory): Persistent file-based memory that survives across sessions. Bootstrap with `coyote --init-memory [global|workspace]`. * [Memory](https://github.com/Dark-Alex-17/coyote/wiki/Memory): Persistent file-based memory that survives across sessions. Bootstrap with `coyote --init-memory [global|workspace]`.
* [Workspace Instructions](https://github.com/Dark-Alex-17/coyote/wiki/Workspace-Instructions): Human-curated project instructions (`COYOTE.md`) injected into every prompt, with `AGENTS.md`/`CLAUDE.md`/`GEMINI.md` fallbacks for cross-tool compatibility. Scaffold with `coyote --init-instructions`.
* [Roles](https://github.com/Dark-Alex-17/coyote/wiki/Roles): Customize model behavior for specific tasks or domains. * [Roles](https://github.com/Dark-Alex-17/coyote/wiki/Roles): Customize model behavior for specific tasks or domains.
* [Skills](https://github.com/Dark-Alex-17/coyote/wiki/Skills): Modular knowledge or capability packs the LLM can load and unload mid-conversation. Multiple skills compose; instructions stack, tools and MCPs union. * [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. * [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.
@@ -60,7 +62,7 @@ Coyote requires the following tools to be installed on your system:
* [uv](https://docs.astral.sh/uv/getting-started/installation/) * [uv](https://docs.astral.sh/uv/getting-started/installation/)
* `curl -LsSf https://astral.sh/uv/install.sh | sh` * `curl -LsSf https://astral.sh/uv/install.sh | sh`
* [iwe](https://github.com/iwe-org/iwe) (`iwec`, for the built-in `iwe` MCP server that navigates large markdown knowledgebases) * [iwe](https://github.com/iwe-org/iwe) (`iwec`, for the built-in `iwe` MCP server that navigates large markdown knowledgebases)
* **Homebrew:** `brew tap iwe-org/iwe && brew install iwe` * **Homebrew:** `brew tap iwe-org/iwe && brew trust --formula iwe-org/iwe/iwe && brew install iwe`
* **Cargo:** `cargo install iwec` * **Cargo:** `cargo install iwec`
* [ast-grep](https://ast-grep.github.io/) (for the built-in `ast_grep` structural code search tool, used by the `explore` agent) * [ast-grep](https://ast-grep.github.io/) (for the built-in `ast_grep` structural code search tool, used by the `explore` agent)
* **Homebrew:** `brew install ast-grep` * **Homebrew:** `brew install ast-grep`
@@ -100,6 +102,32 @@ To upgrade `coyote` using Homebrew:
brew upgrade coyote brew upgrade coyote
``` ```
### Docker
Coyote is available as a Docker image on Docker Hub (`darkalex17/coyote`) for Linux amd64 and arm64.
Useful for CI, ephemeral environments, or anywhere you prefer not to install it natively.
```bash
docker pull darkalex17/coyote
docker run --rm -it darkalex17/coyote
```
To persist your configuration across container runs, mount your existing config directory:
```bash
docker run --rm -it \
-v ~/.config/coyote:/home/agent/.config/coyote \
darkalex17/coyote
```
If you use the local vault provider and want your vault credentials available in the container, also mount the password file:
```bash
docker run --rm -it \
-v ~/.config/coyote:/home/agent/.config/coyote \
-v ~/.coyote_password:/home/agent/.coyote_password:ro \
darkalex17/coyote
```
### Scripts ### Scripts
#### Linux/MacOS (`bash`) #### Linux/MacOS (`bash`)
You can use the following command to run a bash script that downloads and installs the latest version of `coyote` for your You can use the following command to run a bash script that downloads and installs the latest version of `coyote` for your
+94
View File
@@ -0,0 +1,94 @@
# Adversary
An **adversarial plan-conformance reviewer**. Where [`code-reviewer`](../code-reviewer/README.md)
asks *"is this code good?"*, `adversary` asks a different, harder question:
> **"Is this the code the plan asked for — all of it, and only it?"**
It hunts the gap between what a task/plan *specified* and what the implementer actually *built*:
silently skipped acceptance criteria, scope creep, interface substitution, approach drift, and the
requirements that never showed up in the diff at all ("the dog that didn't bark"). It assumes the
implementer drifted until the diff proves otherwise — the independence is the value.
## Why it's separate from `code-reviewer`
| | `code-reviewer` | `adversary` |
|---|---|---|
| Question | Is the code correct/clean/safe? | Does the code match the plan? |
| Input | The diff | The diff **+ the plan's acceptance criteria** |
| Blind spot it covers | slop, bugs, coupling, footguns | skipped criteria, scope drift, contract breakage |
| Output | severity-tagged findings (🔴🟡🟢) | a blocking verdict: `CONFORMS` / `DIVERGES` |
They are **complementary passes**, not substitutes. `sisyphus` runs both on non-trivial work: one
guards quality, the other guards fidelity to the plan.
## Verdict (blocking)
The agent ends every review with one sentinel:
```
ADVERSARIAL_REVIEW: CONFORMS
Criteria: N/N met (all with tests).
```
```
ADVERSARIAL_REVIEW: DIVERGES
Criteria: X/N met, Y partial, Z unmet/diverged.
Complaints:
1. Acceptance criterion "<quoted>" — <Unmet|Partial|Diverged> — <what the diff does/omits, file:line> — <fix>
2. ...
```
A `DIVERGES` verdict **blocks** completion. The caller (sisyphus/architect) must reconcile it —
resume the SAME coder/sisyphus session with the complaints pasted verbatim — or escalate. It mirrors
the `oracle` + `plan-review` gate used before implementation, but applied *after* implementation.
Every complaint ties to a quoted acceptance criterion (or a named scope/interface/out-of-scope
violation) and cites `file:line`. Vague complaints are not emitted.
## How it reviews
Driven by the [`adversarial-review`](../../skills/adversarial-review/SKILL.md) skill:
1. Map **every** acceptance criterion to specific evidence in the diff → ✅ Met / ⚠️ Partial / ❌ Unmet / 🔀 Diverged. No test proving the behavior ⇒ at best ⚠️ Partial.
2. Ground-truth with read-only tools (`fs_grep`/`fs_read`/`ast_grep`): confirm required symbols exist as specified, changes land where they must, new behavior is actually reached, tests target behavior not implementation.
3. Hunt adversarially for the **absent**: skipped criteria, scope creep, interface/approach substitution, out-of-scope touches, downstream contract breakage.
It is **read-only** — it produces a verdict, never a fix.
## Usage
Typically spawned by `sisyphus` (or `architect`) alongside `code-reviewer`. The spawn prompt IS its
entire context, so it must include the diff (or a base ref to fetch) **and** the acceptance criteria:
```sh
agent__spawn --agent adversary --prompt "
## TASK
Adversarially review the recent changes for TASK-NNN against its plan. Return CONFORMS/DIVERGES.
## DIFF
Run get_diff (or --base main), or: <paste diff>
## PLAN — acceptance criteria to check against
<paste the task index.md body + the relevant PLAN-*.md section, verbatim>
"
```
Direct invocation for ad-hoc use:
```sh
coyote -a adversary --agent-variable project_dir /path/to/repo \
"Review staged changes against these criteria: <paste criteria>"
```
### Tools
- `get_diff [--base <ref>]` — staged → unstaged → `HEAD~1` fallback (or an explicit base/PR branch).
- `get_changed_files [--base <ref>]` — quick changed-file map.
- Plus read-only `fs_*` and `ast_grep` for ground-truth checks.
## Related
- [`adversarial-review`](../../skills/adversarial-review/SKILL.md) — the conformance methodology it runs on.
- [`code-reviewer`](../code-reviewer/README.md) — the quality reviewer it runs alongside.
- [`plan-review`](../../skills/plan-review/SKILL.md) — the *pre*-implementation plan gate; `adversary` is its *post*-implementation counterpart.
+115
View File
@@ -0,0 +1,115 @@
name: adversary
description: Adversarial plan-conformance reviewer - judges whether an implementation matches the task/plan it was supposed to satisfy (not code quality). Returns a blocking CONFORMS/DIVERGES verdict. Complements code-reviewer. Designed to be delegated to by sisyphus.
version: 1.0.0
auto_continue: true
max_auto_continues: 15
inject_todo_instructions: true
skills_enabled: true
enabled_skills:
- adversarial-review
variables:
- name: project_dir
description: Project directory containing the changes under review
default: '.'
global_tools:
- ast_grep.sh
- fs_read.sh
- fs_cat.sh
- fs_grep.sh
- fs_glob.sh
- fs_ls.sh
- execute_command.sh
instructions: |
You are an adversarial plan-conformance reviewer. You answer ONE question: **does this
implementation match the plan it was supposed to satisfy — all of it, and only it?** You are NOT
the code-quality reviewer (that is `code-reviewer`/`file-reviewer`, which judges correctness, slop,
and style). You judge CONFORMANCE: skipped acceptance criteria, silent scope drift, interface
substitution, and things the plan required that never showed up in the diff.
Your value is independence and suspicion. Assume the implementer drifted, cut a corner, or misread
the plan until the diff proves otherwise.
## Step 0: Load the skill
Before anything else, `skill__load` `adversarial-review`. It carries your methodology: the
criterion-by-criterion evidence mapping, the adversarial checklist (silently skipped criteria,
scope drift, interface drift, ground-truth verification, out-of-scope violations, downstream
contract breakage), and the exact verdict format. The skill body is your source of truth for HOW to
review and WHAT to flag; these instructions handle workflow and I/O.
## Input (the spawn prompt IS your entire context)
You are given:
1. **The diff** — pasted inline, or run `get_diff` (optionally `--base <ref>`) if told to fetch it.
2. **The plan** — the task's Objective, Tasks, and especially its **Acceptance criteria**, pasted
inline (e.g. a BCP task `index.md` body + the relevant `PLAN-*.md` section), or a path to read.
If the plan / acceptance criteria are missing, STOP and say so: conformance cannot be judged
without a spec. Do not invent criteria or guess intent.
## Workflow
1. Load `adversarial-review`.
2. Get the diff (inline or via `get_diff`) and identify the changed files.
3. For EACH acceptance criterion: find the specific evidence in the diff that satisfies it and
classify it ✅ Met / ⚠️ Partial / ❌ Unmet / 🔀 Diverged. A criterion with no test proving its
behavior is at best ⚠️ Partial.
4. Ground-truth every claim: `fs_grep` the symbols the plan requires (confirm they exist, spelled
as specified), `fs_read` around each hunk to confirm the change makes the criterion true, grep
callers to confirm new behavior is reached, confirm tests target behavior not implementation.
Use `ast_grep` for structural checks (e.g. "was this function signature actually changed?").
5. Hunt adversarially for what's ABSENT (the dog that didn't bark), scope creep, interface/approach
substitution, out-of-scope touches, and downstream contract breakage — per the skill checklist.
6. Emit the verdict in the skill's exact format.
## Output — verdict (MANDATORY, exact format)
End with EXACTLY one of these sentinels so the caller can route on it:
```
ADVERSARIAL_REVIEW: CONFORMS
Criteria: N/N met (all with tests).
<optional: 1-3 non-blocking observations>
```
```
ADVERSARIAL_REVIEW: DIVERGES
Criteria: X/N met, Y partial, Z unmet/diverged.
Complaints:
1. Acceptance criterion "<quoted>" — <Unmet|Partial|Diverged> — <what the diff does/omits, file:line> — <what would make it conform>
2. Scope drift / interface drift / out-of-scope — <file:line> — <the violation> — <the fix>
3. ...
```
Every complaint MUST quote the specific acceptance criterion (or name the specific scope/interface/
out-of-scope violation) AND cite file:line. A complaint with no criterion reference and no location
is noise — do not emit it.
## Rules
1. **You are read-only.** Never modify files. You produce a verdict; the implementer owns the fix.
2. **Conformance, not quality.** Do not flag style/naming/micro-optimizations unless they cause a
criterion to be unmet. If a quality defect breaks a criterion (a race violating a correctness
criterion), flag it as a conformance failure and note it is also a quality issue.
3. **No test ⇒ not met.** An acceptance criterion is a promise of observable behavior; unproven
behavior is at best Partial.
4. **Absence is a finding.** Review what SHOULD be in the diff per the plan, not only what IS.
5. **Don't re-litigate a settled decision** — but DO flag when the diff silently overrode one the
plan recorded ("do X not Y because Z" → diff does Y).
6. **The plan can be the culprit.** If the plan is impossible/self-contradictory, that is DIVERGES
with the plan named as root cause — never judge against a plan you silently corrected.
7. Be terse and decisive. Three real divergences beat fifteen weak ones. If everything is a nitpick,
it CONFORMS — say so.
## Context
- Project: {{project_dir}}
- CWD: {{__cwd__}}
- Shell: {{__shell__}}
## Available Tools
{{__tools__}}
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -eo pipefail
# @env LLM_OUTPUT=/dev/stdout
# @env LLM_AGENT_VAR_PROJECT_DIR=.
# @describe Adversarial plan-conformance reviewer tools
_project_dir() {
local dir="${LLM_AGENT_VAR_PROJECT_DIR:-.}"
(cd "${dir}" 2>/dev/null && pwd) || echo "${dir}"
}
# @cmd Get the git diff to review for plan conformance. 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 PR base branch)
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 review 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 of what to check against the plan).
# @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 -1
View File
@@ -16,7 +16,7 @@ agents while handling coordination and final reporting.
## Pro-Tip: Use an IDE MCP Server for Improved Performance ## Pro-Tip: Use an IDE MCP Server for Improved Performance
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
server to your config (see the [MCP Server docs](../../../docs/function-calling/MCP-SERVERS.md) to see how to configure server to your config (see the [MCP Server docs](https://github.com/Dark-Alex-17/coyote/wiki/MCP-Servers) to see how to configure
them), and modify the agent definition to look like this: them), and modify the agent definition to look like this:
```yaml ```yaml
+2 -1
View File
@@ -21,6 +21,7 @@ variables:
default: '.' default: '.'
global_tools: global_tools:
- ast_grep.sh
- fs_read.sh - fs_read.sh
- fs_cat.sh - fs_cat.sh
- fs_grep.sh - fs_grep.sh
@@ -158,6 +159,6 @@ instructions: |
- Project: {{project_dir}} - Project: {{project_dir}}
- CWD: {{__cwd__}} - CWD: {{__cwd__}}
- Shell: {{__shell__}} - Shell: {{__shell__}}
## Available Tools: ## Available Tools:
{{__tools__}} {{__tools__}}
+29 -22
View File
@@ -2,9 +2,9 @@ name: coder
description: | description: |
Implementation agent. Plans, implements, and runs build + tests in a Implementation agent. Plans, implements, and runs build + tests in a
bounded fix-loop until verified. Designed to be delegated to by sisyphus. bounded fix-loop until verified. Designed to be delegated to by sisyphus.
version: "1.0" version: '1.0'
global_tools: global_tools:
- ast_grep.sh
- fs_cat.sh - fs_cat.sh
- fs_ls.sh - fs_ls.sh
- fs_write.sh - fs_write.sh
@@ -25,7 +25,7 @@ variables:
Absolute path to the project directory. Defaults to "." which is the Absolute path to the project directory. Defaults to "." which is the
directory you invoked `coyote` from. Override at runtime with directory you invoked `coyote` from. Override at runtime with
`coyote -a coder --agent-variable project_dir /abs/path "..."`. `coyote -a coder --agent-variable project_dir /abs/path "..."`.
default: "." default: '.'
settings: settings:
max_loop_iterations: 20 max_loop_iterations: 20
@@ -34,14 +34,14 @@ settings:
timeout: 1800 timeout: 1800
initial_state: initial_state:
project_dir: "" project_dir: ''
fix_attempts: 0 fix_attempts: 0
max_fix_attempts: 3 max_fix_attempts: 3
fix_instructions: "" fix_instructions: ''
build_output: "" build_output: ''
tests_output: "" tests_output: ''
last_node_output: "" last_node_output: ''
plan_summary: "" plan_summary: ''
files_to_modify: [] files_to_modify: []
files_to_create: [] files_to_create: []
risks: [] risks: []
@@ -49,7 +49,7 @@ initial_state:
review_attempts: 0 review_attempts: 0
max_review_attempts: 1 max_review_attempts: 1
review_clean: true review_clean: true
review_notes: "" review_notes: ''
start: resolve_paths start: resolve_paths
@@ -88,7 +88,7 @@ nodes:
etc. Empty list is fine. etc. Empty list is fine.
Project directory: {{project_dir}} Project directory: {{project_dir}}
prompt: "{{initial_prompt}}" prompt: '{{initial_prompt}}'
tools: [] tools: []
output_schema: output_schema:
type: object type: object
@@ -98,20 +98,27 @@ nodes:
description: 1-3 sentences summarizing what will be done description: 1-3 sentences summarizing what will be done
files_to_modify: files_to_modify:
type: array type: array
items: {type: string} items: { type: string }
files_to_create: files_to_create:
type: array type: array
items: {type: string} items: { type: string }
complexity_score: complexity_score:
type: integer type: integer
minimum: 1 minimum: 1
maximum: 10 maximum: 10
risks: risks:
type: array type: array
items: {type: string} items: { type: string }
required: [plan_summary, files_to_modify, files_to_create, complexity_score, risks] required:
[
plan_summary,
files_to_modify,
files_to_create,
complexity_score,
risks,
]
state_updates: state_updates:
last_node_output: "{{output}}" last_node_output: '{{output}}'
fallback: end_failure fallback: end_failure
next: route_complexity next: route_complexity
@@ -144,11 +151,11 @@ nodes:
Approve this plan? Approve this plan?
options: options:
- "yes" - 'yes'
- "no" - 'no'
routes: routes:
"yes": implement 'yes': implement
"no": end_rejected 'no': end_rejected
on_other: end_rejected on_other: end_rejected
implement: implement:
@@ -243,7 +250,7 @@ nodes:
- execute_command - execute_command
max_iterations: 30 max_iterations: 30
state_updates: state_updates:
last_node_output: "{{output}}" last_node_output: '{{output}}'
fallback: end_failure fallback: end_failure
next: verify_build next: verify_build
@@ -326,7 +333,7 @@ nodes:
description: Concrete issues found, one per line as file:line - description. Empty when review_clean is true. description: Concrete issues found, one per line as file:line - description. Empty when review_clean is true.
required: [review_clean, review_notes] required: [review_clean, review_notes]
state_updates: state_updates:
last_node_output: "{{output}}" last_node_output: '{{output}}'
fallback: end_success fallback: end_success
next: route_review_result next: route_review_result
+3 -2
View File
@@ -1,5 +1,5 @@
name: explore name: explore
description: Fast codebase exploration agent - finds patterns, structures, and relevant files. Designed to be fanned out 2-5 in parallel by orchestrators. description: Fast codebase exploration agent - finds patterns, structures, and relevant files. Designed to be fanned out in parallel by orchestrators — scale to the number of distinct search angles the task requires.
version: 3.1.0 version: 3.1.0
skills_enabled: true skills_enabled: true
@@ -14,6 +14,7 @@ variables:
mcp_servers: mcp_servers:
- ddg-search - ddg-search
global_tools: global_tools:
- ast_grep.sh
- fs_read.sh - fs_read.sh
- fs_cat.sh - fs_cat.sh
- fs_grep.sh - fs_grep.sh
@@ -34,7 +35,7 @@ instructions: |
## You may be one of many parallel explorers ## You may be one of many parallel explorers
Orchestrators (like Sisyphus) often fan out 2-5 explore agents at once, each covering a different angle of the same question. Assume you are ONE narrow slice of a larger investigation. Stay strictly within YOUR slice as defined by the prompt — don't broaden scope to cover what other parallel explorers might be handling. Orchestrators (like Sisyphus) fan out as many explore agents as the task warrants — one per distinct search angle, module boundary, or concern. You may be one of many running in parallel. Assume you are ONE narrow slice of a larger investigation. Stay strictly within YOUR slice as defined by the prompt — don't broaden scope to cover what other parallel explorers might be handling.
If the prompt says "find auth middleware", you find auth middleware. You do NOT also tour the routing layer, the error system, and the database connection pool. Narrow scope is the contract. If the prompt says "find auth middleware", you find auth middleware. You do NOT also tour the routing layer, the error system, and the database connection pool. Narrow scope is the contract.
+1 -1
View File
@@ -16,7 +16,7 @@ one file while communicating with sibling agents to catch issues that span multi
## Pro-Tip: Use an IDE MCP Server for Improved Performance ## Pro-Tip: Use an IDE MCP Server for Improved Performance
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
server to your config (see the [MCP Server docs](../../../docs/function-calling/MCP-SERVERS.md) to see how to configure server to your config (see the [MCP Server docs](https://github.com/Dark-Alex-17/coyote/wiki/MCP-Servers) to see how to configure
them), and modify the agent definition to look like this: them), and modify the agent definition to look like this:
```yaml ```yaml
-2
View File
@@ -8,8 +8,6 @@ description: |
sisyphus alongside explore when unfamiliar libraries/APIs/frameworks are sisyphus alongside explore when unfamiliar libraries/APIs/frameworks are
involved. involved.
Iteration 3: smart triage node up front + final-format trim of LLM
narrative leakage.
version: "1.0" version: "1.0"
global_tools: global_tools:
+1
View File
@@ -18,6 +18,7 @@ variables:
mcp_servers: mcp_servers:
- ddg-search - ddg-search
global_tools: global_tools:
- ast_grep.sh
- fs_read.sh - fs_read.sh
- fs_cat.sh - fs_cat.sh
- fs_grep.sh - fs_grep.sh
+47 -3
View File
@@ -8,6 +8,14 @@ max_auto_continues: 25
inject_todo_instructions: true inject_todo_instructions: true
can_spawn_agents: true can_spawn_agents: true
spawnable_agents:
- explore
- librarian
- coder
- oracle
- code-reviewer
- adversary
- step-runner
max_concurrent_agents: 4 max_concurrent_agents: 4
max_agent_depth: 3 max_agent_depth: 3
inject_spawn_instructions: true inject_spawn_instructions: true
@@ -39,6 +47,7 @@ variables:
mcp_servers: mcp_servers:
- ddg-search - ddg-search
global_tools: global_tools:
- ast_grep.sh
- fs_read.sh - fs_read.sh
- fs_grep.sh - fs_grep.sh
- fs_glob.sh - fs_glob.sh
@@ -128,8 +137,8 @@ instructions: |
| Agent | Use For | Characteristics | | Agent | Use For | Characteristics |
|-------|---------|-----------------| |-------|---------|-----------------|
| `explore` | Find patterns in THIS codebase, understand local code | Read-only, returns findings, fan out 2-5 in parallel | | `explore` | Find patterns in THIS codebase, understand local code | Read-only, returns findings, fan out as many as the task warrants — one per distinct search angle, module, or concern. Large codebases or cross-cutting tasks should spawn 515+. |
| `librarian` | Find official docs, OSS examples, web best practices for EXTERNAL libraries | Read-only, returns citation-backed findings, fan out 1-3 in parallel | | `librarian` | Find official docs, OSS examples, web best practices for EXTERNAL libraries | Read-only, returns citation-backed findings, fan out as many as distinct external sources or questions warrant — typically 26, more if the topic spans multiple libraries or specs. |
| `coder` | Write/edit files, implement features | Graph agent: plan → approval → implement → verify build+tests → self_review → bounded fix-loop | | `coder` | Write/edit files, implement features | Graph agent: plan → approval → implement → verify build+tests → self_review → bounded fix-loop |
| `oracle` | Architecture, complex debugging, review, plan review | Advisory, blocking — never answer the user before collecting Oracle results | | `oracle` | Architecture, complex debugging, review, plan review | Advisory, blocking — never answer the user before collecting Oracle results |
| `step-runner` | Execute ONE step of a phased plan repo (Phase 8) | Graph agent: orient → staleness check → coder → verify → handoff → user approval gate | | `step-runner` | Execute ONE step of a phased plan repo (Phase 8) | Graph agent: orient → staleness check → coder → verify → handoff → user approval gate |
@@ -194,7 +203,17 @@ instructions: |
## Phase 4 - Parallel Research ## Phase 4 - Parallel Research
When delegating exploration, load `parallel-research` skill, then fan out 2-5 `explore` agents in parallel, each scoped to a different angle. Each gets a NARROW slice. When delegating exploration, load `parallel-research` skill, then fan out `explore` agents in parallel — one per distinct search angle, module boundary, or concern. Each gets a NARROW slice. Scale to the task:
| Task scope | Suggested fan-out |
|---|---|
| Single feature, known location | 23 |
| Multi-file feature across 2-3 modules | 46 |
| Cross-cutting concern (auth, error handling, config) across whole codebase | 712 |
| Large refactor or architectural analysis spanning many modules | 1020+ |
| Full codebase audit (security, performance, pattern consistency) | One agent per top-level module or package |
Never artificially cap at a small number. If there are 10 distinct things to find, spawn 10 agents. The system limit is the only ceiling that matters.
### The wait protocol ### The wait protocol
@@ -286,6 +305,31 @@ instructions: |
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. 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.
### Adversarial plan-conformance review (post-coder, when the work implements a plan/spec)
`code-reviewer` asks "is this code good?" It does NOT check "is this the code the plan asked for?" When the coder work implemented against a written spec — a task file, a `plans/` step, an acceptance-criteria list, or any request with explicit "done when …" criteria — spawn `adversary` for an independent conformance pass. It maps every acceptance criterion to evidence in the diff and hunts for silently-skipped criteria, scope drift, interface substitution, and requirements that never landed ("the dog that didn't bark").
**When to spawn it:** whenever the change has a checkable spec. This is orthogonal to the `code-reviewer` thresholds — a one-file change can still silently skip an acceptance criterion. If there is a plan/task/criteria list, run `adversary`. Run BOTH reviewers when the work is both broad (code-reviewer thresholds fire) AND spec-driven; they cover different failure modes and their prompts differ (code-reviewer gets the diff; adversary gets the diff PLUS the acceptance criteria).
**Spawn pattern** (the prompt IS its whole context — it MUST include the criteria):
```
agent__spawn --agent adversary --prompt "Adversarially review the recent coder change(s) for conformance to the plan. Return CONFORMS/DIVERGES.
DIFF: run get_diff (or --base <ref>), or: <paste diff>
PLAN — acceptance criteria to check against:
<paste the task/step spec + acceptance criteria VERBATIM — not a summary>"
```
### Handling adversary findings
- **`ADVERSARIAL_REVIEW: DIVERGES` blocks completion.** Do not mark the task done. Resume the SAME coder session (`agent__spawn --session_id <id> --prompt "Fix these plan-conformance failures: <complaints pasted verbatim>"`) — do not spawn a fresh coder. After the fix, re-run `adversary` ONCE to confirm it now CONFORMS; if it still DIVERGES on the same criteria after one fix cycle, STOP and escalate to the user (the plan or the approach may be wrong — consider `oracle`).
- **`ADVERSARIAL_REVIEW: CONFORMS`** — conformance satisfied; proceed (subject to code-reviewer's quality findings still being resolved).
- **A complaint that the PLAN itself is the root cause** (impossible/contradictory criterion) — do NOT silently "fix" by changing scope. Surface it to the user; the plan needs amending, which is their call.
Unlike `code-reviewer`, re-running `adversary` once after a conformance fix is expected — a DIVERGES verdict is a hard gate, and confirming the fix actually closed it is the point.
## File Operations (Direct Edits) ## File Operations (Direct Edits)
When you write or modify files yourself (rather than delegating to coder): When you write or modify files yourself (rather than delegating to coder):
+60 -53
View File
@@ -5,9 +5,9 @@ description: |
implement (coder) -> verify -> edge-case sweep -> optional independent implement (coder) -> verify -> edge-case sweep -> optional independent
review -> evidence-backed handoff -> user approval gate. Designed to be review -> evidence-backed handoff -> user approval gate. Designed to be
delegated to by sisyphus. delegated to by sisyphus.
version: "1.0" version: '1.0'
global_tools: global_tools:
- ast_grep.sh
- fs_cat.sh - fs_cat.sh
- fs_ls.sh - fs_ls.sh
- fs_write.sh - fs_write.sh
@@ -28,18 +28,18 @@ variables:
coyote was invoked from). The coder sub-agent resolves its own coyote was invoked from). The coder sub-agent resolves its own
project_dir the same way, so invoke step-runner FROM the project root project_dir the same way, so invoke step-runner FROM the project root
unless you override this for both. unless you override this for both.
default: "." default: '.'
- name: plans_dir - name: plans_dir
description: | description: |
Path to the plan repo. Relative paths resolve against project_dir. Path to the plan repo. Relative paths resolve against project_dir.
Expected layout: <plans_dir>/steps/NN-<slug>.md, Expected layout: <plans_dir>/steps/NN-<slug>.md,
<plans_dir>/handoffs/, <plans_dir>/NOTES.md. <plans_dir>/handoffs/, <plans_dir>/NOTES.md.
default: "plans" default: 'plans'
- name: step - name: step
description: | description: |
Which step to execute: a step number, or "next" to pick the first Which step to execute: a step number, or "next" to pick the first
in-progress (resume) or pending step plan. in-progress (resume) or pending step plan.
default: "next" default: 'next'
settings: settings:
max_loop_iterations: 20 max_loop_iterations: 20
@@ -48,45 +48,45 @@ settings:
timeout: 7200 timeout: 7200
initial_state: initial_state:
project_dir: "" project_dir: ''
plans_dir: "" plans_dir: ''
step_number: 0 step_number: 0
step_slug: "" step_slug: ''
step_title: "" step_title: ''
step_plan_path: "" step_plan_path: ''
step_plan: "" step_plan: ''
prev_handoff_path: "(none)" prev_handoff_path: '(none)'
prev_handoff: "(none - this is the first step)" prev_handoff: '(none - this is the first step)'
notes_path: "" notes_path: ''
notes: "(none)" notes: '(none)'
handoff_path: "" handoff_path: ''
blocking_reason: "" blocking_reason: ''
plan_summary: "" plan_summary: ''
implementation_brief: "" implementation_brief: ''
staleness_report: "" staleness_report: ''
has_major_deviation: false has_major_deviation: false
deviation_summary: "" deviation_summary: ''
user_feedback: "" user_feedback: ''
fix_instructions: "" fix_instructions: ''
fix_attempts: 0 fix_attempts: 0
max_fix_attempts: 2 max_fix_attempts: 2
coder_result: "" coder_result: ''
format_output: "" format_output: ''
lint_ok: true lint_ok: true
lint_output: "" lint_output: ''
build_ok: true build_ok: true
build_output: "" build_output: ''
tests_ok: true tests_ok: true
tests_output: "" tests_output: ''
edge_case_report: "" edge_case_report: ''
downstream_updates: "" downstream_updates: ''
needs_independent_review: false needs_independent_review: false
review_report: "" review_report: ''
review_attempts: 0 review_attempts: 0
max_review_attempts: 1 max_review_attempts: 1
handoff_attempts: 0 handoff_attempts: 0
handoff_fix: "" handoff_fix: ''
step_summary: "" step_summary: ''
start: resolve_step start: resolve_step
@@ -114,11 +114,11 @@ nodes:
Proceed anyway? Proceed anyway?
options: options:
- "yes" - 'yes'
- "no" - 'no'
routes: routes:
"yes": orient 'yes': orient
"no": end_blocked 'no': end_blocked
on_other: end_blocked on_other: end_blocked
orient: orient:
@@ -183,7 +183,14 @@ nodes:
deviation_summary: deviation_summary:
type: string type: string
description: Major deviations only, with the plan claim vs current reality. Empty when none description: Major deviations only, with the plan claim vs current reality. Empty when none
required: [plan_summary, implementation_brief, staleness_report, has_major_deviation, deviation_summary] required:
[
plan_summary,
implementation_brief,
staleness_report,
has_major_deviation,
deviation_summary,
]
fallback: end_failure fallback: end_failure
next: route_staleness next: route_staleness
@@ -211,14 +218,14 @@ nodes:
Proceed with the corrected brief? (Answer with anything else to give Proceed with the corrected brief? (Answer with anything else to give
your own guidance to the implementer.) your own guidance to the implementer.)
options: options:
- "proceed" - 'proceed'
- "abort" - 'abort'
routes: routes:
"proceed": implement 'proceed': implement
"abort": end_rejected 'abort': end_rejected
on_other: implement on_other: implement
state_updates: state_updates:
user_feedback: "{{choice}}" user_feedback: '{{choice}}'
implement: implement:
id: implement id: implement
@@ -262,7 +269,7 @@ nodes:
{{fix_instructions}} {{fix_instructions}}
timeout: 3600 timeout: 3600
state_updates: state_updates:
coder_result: "{{output}}" coder_result: '{{output}}'
next: route_coder_result next: route_coder_result
route_coder_result: route_coder_result:
@@ -399,7 +406,7 @@ nodes:
Preserve severity tags in your findings. Preserve severity tags in your findings.
timeout: 1200 timeout: 1200
state_updates: state_updates:
review_report: "{{output}}" review_report: '{{output}}'
next: route_review next: route_review
route_review: route_review:
@@ -517,23 +524,23 @@ nodes:
Approve this step? (Answer with anything else to send revision Approve this step? (Answer with anything else to send revision
instructions straight to the implementer.) instructions straight to the implementer.)
options: options:
- "approve" - 'approve'
- "revise" - 'revise'
routes: routes:
"approve": end_success 'approve': end_success
"revise": get_revision 'revise': get_revision
on_other: revise_from_choice on_other: revise_from_choice
state_updates: state_updates:
user_feedback: "{{choice}}" user_feedback: '{{choice}}'
get_revision: get_revision:
id: get_revision id: get_revision
type: input type: input
description: Collect revision instructions, then loop back through implement -> verify -> handoff. description: Collect revision instructions, then loop back through implement -> verify -> handoff.
question: "What should change? Your comments go to the implementer verbatim." question: 'What should change? Your comments go to the implementer verbatim.'
validation: "len(input) > 0" validation: 'len(input) > 0'
state_updates: state_updates:
fix_instructions: "{{input}}" fix_instructions: '{{input}}'
next: implement next: implement
revise_from_choice: revise_from_choice:
+10 -2
View File
@@ -10,5 +10,13 @@ set -e
main() { main() {
# shellcheck disable=SC2154 # shellcheck disable=SC2154
cat "$argc_path" >> "$LLM_OUTPUT" 2>&1 || echo "No such file or path: $argc_path" >> "$LLM_OUTPUT" local path="$argc_path"
}
# An empty result is shown to the model as the opaque literal "DONE"; emit a note instead.
if [[ -f "$path" && ! -s "$path" ]]; then
echo "(empty file: $path)" >> "$LLM_OUTPUT"
return 0
fi
cat "$path" >> "$LLM_OUTPUT" 2>&1 || echo "No such file or path: $path" >> "$LLM_OUTPUT"
}
+2 -2
View File
@@ -17,8 +17,8 @@ main() {
local search_path="${argc_path:-.}" local search_path="${argc_path:-.}"
if [[ ! -d "$search_path" ]]; then if [[ ! -d "$search_path" ]]; then
echo "Error: directory not found: $search_path" >> "$LLM_OUTPUT" echo "Error: directory not found: $search_path" >&2
return 1 exit 1
fi fi
local results local results
+2 -2
View File
@@ -21,8 +21,8 @@ main() {
local include_filter="${argc_include:-}" local include_filter="${argc_include:-}"
if [[ ! -e "$search_path" ]]; then if [[ ! -e "$search_path" ]]; then
echo "Error: path not found: $search_path" >> "$LLM_OUTPUT" echo "Error: path not found: $search_path" >&2
return 1 exit 1
fi fi
local grep_args=(-nH --color=never) local grep_args=(-nH --color=never)
+15 -2
View File
@@ -9,5 +9,18 @@ set -e
main() { main() {
# shellcheck disable=SC2154 # shellcheck disable=SC2154
ls -1 "$argc_path" >> "$LLM_OUTPUT" 2>&1 || echo "No such path: $argc_path" >> "$LLM_OUTPUT" local path="$argc_path"
} local output
if ! output=$(ls -1 "$path" 2>&1); then
echo "$output" >> "$LLM_OUTPUT"
return 0
fi
# An empty result is shown to the model as the opaque literal "DONE"; emit a note instead.
if [[ -z "$output" ]]; then
echo "(empty directory: $path)" >> "$LLM_OUTPUT"
else
echo "$output" >> "$LLM_OUTPUT"
fi
}
+18 -6
View File
@@ -8,8 +8,8 @@ set -e
# Use the grep tool to find specific content before reading, then read with offset to target the relevant section. # Use the grep tool to find specific content before reading, then read with offset to target the relevant section.
# @option --path! The absolute path to the file or directory to read # @option --path! The absolute path to the file or directory to read
# @option --offset The line number to start reading from (1-indexed, default: 1) # @option --offset <INT> The line number to start reading from (1-indexed, default: 1)
# @option --limit The maximum number of lines to read (default: 2000) # @option --limit <INT> The maximum number of lines to read (default: 2000)
# @env LLM_OUTPUT=/dev/stdout The output path # @env LLM_OUTPUT=/dev/stdout The output path
@@ -23,8 +23,8 @@ main() {
local limit="${argc_limit:-2000}" local limit="${argc_limit:-2000}"
if [[ ! -e "$target" ]]; then if [[ ! -e "$target" ]]; then
echo "Error: path not found: $target" >> "$LLM_OUTPUT" echo "Error: path not found: $target" >&2
return 1 exit 1
fi fi
if [[ -d "$target" ]]; then if [[ -d "$target" ]]; then
@@ -33,9 +33,20 @@ main() {
fi fi
local total_lines file_bytes local total_lines file_bytes
total_lines=$(wc -l < "$target" 2>/dev/null || echo 0) # awk counts a final line that lacks a trailing newline; wc -l would undercount it by one.
total_lines=$(awk 'END { print NR }' "$target" 2>/dev/null || echo 0)
file_bytes=$(wc -c < "$target" 2>/dev/null || echo 0) file_bytes=$(wc -c < "$target" 2>/dev/null || echo 0)
if [[ "$total_lines" -eq 0 ]]; then
echo "(file is empty: $target)" >> "$LLM_OUTPUT"
return 0
fi
if [[ "$offset" -gt "$total_lines" ]]; then
echo "(offset $offset is past the end of the file, which has $total_lines lines)" >> "$LLM_OUTPUT"
return 0
fi
if [[ "$file_bytes" -gt "$MAX_BYTES" ]] && [[ "$offset" -eq 1 ]] && [[ "$limit" -ge 2000 ]]; then if [[ "$file_bytes" -gt "$MAX_BYTES" ]] && [[ "$offset" -eq 1 ]] && [[ "$limit" -ge 2000 ]]; then
{ {
echo "Warning: Large file (${file_bytes} bytes, ${total_lines} lines). Showing first ${limit} lines." echo "Warning: Large file (${file_bytes} bytes, ${total_lines} lines). Showing first ${limit} lines."
@@ -48,7 +59,8 @@ main() {
sed -n "${offset},${end_line}p" "$target" 2>/dev/null | { sed -n "${offset},${end_line}p" "$target" 2>/dev/null | {
local line_num=$offset local line_num=$offset
while IFS= read -r line; do # `|| [[ -n "$line" ]]` keeps the final line when the file has no trailing newline.
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ ${#line} -gt $MAX_LINE_LENGTH ]]; then if [[ ${#line} -gt $MAX_LINE_LENGTH ]]; then
line="${line:0:$MAX_LINE_LENGTH}... (truncated)" line="${line:0:$MAX_LINE_LENGTH}... (truncated)"
fi fi
+5 -1
View File
@@ -552,7 +552,7 @@ patch_file() {
continue continue
} }
if (line ~ /^@@ /) { if (line ~ /^@@/) {
mode = "hunk" mode = "hunk"
hunkIndex++ hunkIndex++
patchLineIndex++ patchLineIndex++
@@ -585,6 +585,10 @@ patch_file() {
if (hunkIndex == 0) { if (hunkIndex == 0) {
print "error: no patch" > "/dev/stderr" print "error: no patch" > "/dev/stderr"
print "" > "/dev/stderr"
print "No hunk header was found. Each hunk must start with a line beginning \"@@\"" > "/dev/stderr"
print "(for example \"@@ ... @@\" or \"@@ -1,4 +1,4 @@\"). Inside a hunk, context lines" > "/dev/stderr"
print "start with a single space, removed lines with \"-\", and added lines with \"+\"." > "/dev/stderr"
exit 1 exit 1
} }
+4
View File
@@ -82,6 +82,10 @@ Additional hard rules:
- If the evidence points to failing hardware or risk of data loss, stop, say so plainly, and present options before - If the evidence points to failing hardware or risk of data loss, stop, say so plainly, and present options before
touching anything else. touching anything else.
## When to Stop Gathering Evidence
Once you have two or more independent pieces of evidence pointing to the same root cause, **stop gathering and deliver your diagnosis**. Do not add more verification steps to verify your verification. If you notice yourself thinking "let me just confirm one more thing" after you have already reached a conclusion, that is the signal to stop and explain the diagnosis instead. More data is not always better — a timely diagnosis with strong evidence beats an exhaustive audit.
## Communication ## Communication
- Lead with what you found, not what you did. Then show the key evidence: the command and the relevant lines of its - Lead with what you found, not what you did. Then show the key evidence: the command and the relevant lines of its
+2 -2
View File
@@ -9,8 +9,8 @@ security/configuration settings. The analysis aims to ensure a thorough understa
structured and operates, enabling the creation of new files, maintaining consistency with existing practices, and the structured and operates, enabling the creation of new files, maintaining consistency with existing practices, and the
potential implementation of best practices. potential implementation of best practices.
Should the root directory contain a `COYOTE.md` file, this was generated by Coyote and should be used as a reference Should the root directory contain a `COYOTE.md` (or `AGENTS.md`/`CLAUDE.md`) file, this contains human-curated project
point for all analysis, style questions, etc. instructions and should be used as a reference point for all analysis, style questions, etc.
**Objective:** Enable the AI to thoroughly analyze a software repository, providing detailed insights and guidelines on **Objective:** Enable the AI to thoroughly analyze a software repository, providing detailed insights and guidelines on
all relevant aspects for understanding and potentially contributing to the project. all relevant aspects for understanding and potentially contributing to the project.
+48 -106
View File
@@ -5,7 +5,7 @@
# sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/ # sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/
# sbx cp $HOME/.coyote_password testing:/home/agent/ # sbx cp $HOME/.coyote_password testing:/home/agent/
# sbx run testing --kit ./sbx-kit/ # sbx run testing --kit ./sbx-kit/
schemaVersion: "1" schemaVersion: '1'
kind: sandbox kind: sandbox
name: coyote name: coyote
displayName: Coyote displayName: Coyote
@@ -14,10 +14,10 @@ description: >
CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros. CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros.
sandbox: sandbox:
image: "docker/sandbox-templates:shell-docker" image: 'darkalex17/coyote:v0.7.4'
aiFilename: COYOTE.md aiFilename: COYOTE.md
entrypoint: entrypoint:
run: ["bash", "-lc", "exec /home/agent/.cargo/bin/coyote"] run: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote']
network: network:
# Proxy-managed LLM providers: the proxy substitutes `proxy-managed` for # Proxy-managed LLM providers: the proxy substitutes `proxy-managed` for
@@ -50,96 +50,96 @@ network:
serviceAuth: serviceAuth:
openai: openai:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
anthropic: anthropic:
headerName: x-api-key headerName: x-api-key
valueFormat: "%s" valueFormat: '%s'
gemini: gemini:
headerName: x-goog-api-key headerName: x-goog-api-key
valueFormat: "%s" valueFormat: '%s'
cohere: cohere:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
groq: groq:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
openrouter: openrouter:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
ai21: ai21:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
cloudflare: cloudflare:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
deepinfra: deepinfra:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
deepseek: deepseek:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
mistral: mistral:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
perplexity: perplexity:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
voyageai: voyageai:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
xai: xai:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
jina: jina:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
ernie: ernie:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
hunyuan: hunyuan:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
minimax: minimax:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
moonshot: moonshot:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
qianwen: qianwen:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
zhipuai: zhipuai:
headerName: Authorization headerName: Authorization
valueFormat: "Bearer %s" valueFormat: 'Bearer %s'
allowedDomains: allowedDomains:
# Coyote release + self-update + model-registry sync # Coyote release + self-update + model-registry sync
- "github.com:443" - 'github.com:443'
- "api.github.com:443" - 'api.github.com:443'
- "raw.githubusercontent.com:443" - 'raw.githubusercontent.com:443'
- "objects.githubusercontent.com:443" - 'objects.githubusercontent.com:443'
- "*.githubusercontent.com:443" - '*.githubusercontent.com:443'
# Coyote install paths (cargo install + uv + rustup + Python tool deps at runtime) # Package managers and developer tools (cargo, uv, pip — useful at runtime for user installs)
- "crates.io:443" - 'crates.io:443'
- "static.crates.io:443" - 'static.crates.io:443'
- "pypi.org:443" - 'pypi.org:443'
- "files.pythonhosted.org:443" - 'files.pythonhosted.org:443'
- "astral.sh:443" - 'astral.sh:443'
- "sh.rustup.rs:443" - 'sh.rustup.rs:443'
- "static.rust-lang.org:443" - 'static.rust-lang.org:443'
# LLM model OAuth + API endpoints # LLM model OAuth + API endpoints
- "claude.ai:443" - 'claude.ai:443'
- "console.anthropic.com:443" - 'console.anthropic.com:443'
- "accounts.google.com:443" - 'accounts.google.com:443'
# *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints # *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints
# (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI. # (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI.
- "*.googleapis.com:443" - '*.googleapis.com:443'
# Bedrock and GitHub Models use signed / GitHub-PAT auth that the proxy # Bedrock and GitHub Models use signed / GitHub-PAT auth that the proxy
# cannot rewrite. Domains are allow-listed; credentials must be injected # cannot rewrite. Domains are allow-listed; credentials must be injected
# separately (see README "Extending"). # separately (see README "Extending").
- "*.amazonaws.com:443" - '*.amazonaws.com:443'
- "models.inference.ai.azure.com:443" - 'models.inference.ai.azure.com:443'
credentials: credentials:
sources: sources:
@@ -210,9 +210,10 @@ credentials:
environment: environment:
variables: variables:
IS_SANDBOX: "1" IS_SANDBOX: '1'
COYOTE_LOG_LEVEL: INFO COYOTE_LOG_LEVEL: INFO
COYOTE_CONFIG_DIR: /home/agent/.config/coyote COYOTE_CONFIG_DIR: /home/agent/.config/coyote
EDITOR: nano
proxyManaged: proxyManaged:
- OPENAI_API_KEY - OPENAI_API_KEY
- ANTHROPIC_API_KEY - ANTHROPIC_API_KEY
@@ -238,73 +239,14 @@ environment:
- ZHIPUAI_API_KEY - ZHIPUAI_API_KEY
commands: commands:
install:
- command: |
sudo apt-get update &&
sudo apt-get install -y \
jq curl git \
build-essential pkg-config \
cmake \
clang libclang-dev \
musl-tools \
libssl-dev \
pandoc \
bzip2
user: "1000"
description: Install system prerequisites (including pandoc for fetch_url_via_curl)
- command: |
curl -LsSf https://astral.sh/uv/install.sh | sh
if [ -f "$HOME/.local/bin/uv" ]; then
printf '#!/bin/sh\nexec uv tool run "$@"\n' > "$HOME/.local/bin/uvx"
chmod +x "$HOME/.local/bin/uvx"
fi
user: "1000"
description: Install uv and write a uvx shell wrapper (the installer may place a macOS binary at this path on Docker-for-Mac hosts, which the Linux container cannot execute)
- command: |
set -euo pipefail
USQL_VERSION=0.21.4
ARCH=$(uname -m)
case "$ARCH" in
x86_64) USQL_ARCH=amd64 ;;
aarch64) USQL_ARCH=arm64 ;;
*) echo "Unsupported arch for usql install: $ARCH" >&2; exit 1 ;;
esac
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
curl -fsSL --retry 3 "https://github.com/xo/usql/releases/download/v${USQL_VERSION}/usql_static-${USQL_VERSION}-linux-${USQL_ARCH}.tar.bz2" -o "$TMPDIR/usql.tar.bz2"
tar -xjf "$TMPDIR/usql.tar.bz2" -C "$TMPDIR"
sudo install -m 0755 "$TMPDIR/usql_static" /usr/local/bin/usql
user: "1000"
description: Install the usql universal SQL CLI (used by the built-in sql agent and execute_sql_code tool)
- command: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y \
--default-toolchain stable \
--profile minimal \
--target x86_64-unknown-linux-musl
. "$HOME/.cargo/env"
cargo install --locked coyote-ai
user: "1000"
description: Install Coyote AI CLI via Rust's Cargo
- command: |
. "$HOME/.cargo/env"
cargo install --locked iwec
user: "1000"
description: Install the IWE MCP server binary (iwec) used by the built-in iwe MCP server and iwe-knowledge-base skill
- command: |
. "$HOME/.cargo/env"
cargo install --locked ast-grep
user: "1000"
description: Install ast-grep, used by the built-in ast_grep structural code search tool (and the explore agent)
startup: startup:
- command: - command:
[ [
"sh", 'sh',
"-c", '-c',
'test -f "$HOME/.config/coyote/config.yaml" || coyote --info >/dev/null 2>&1 || true', 'test -f "$HOME/.config/coyote/config.yaml" || coyote --info >/dev/null 2>&1 || true',
] ]
user: "1000" user: '1000'
background: false background: false
description: Bootstrap Coyote config directory on first sandbox start description: Bootstrap Coyote config directory on first sandbox start
+79
View File
@@ -0,0 +1,79 @@
---
description: Adversarial plan-conformance review of an implementation against the task/plan it was supposed to satisfy. Verdict is CONFORMS or DIVERGES with acceptance-criterion-referenced complaints. Grants read-only filesystem access for ground-truth checks. Complements code-review (which judges code quality); this judges whether the code is the RIGHT code per the plan.
enabled_tools: fs_read, fs_grep, fs_glob, fs_cat, fs_ls
---
You are an adversarial plan-conformance reviewer. A code-quality reviewer already asks "is this code good?" — you ask a different, harder question: **"is this the code the plan asked for, and ONLY that?"** You are hunting for the gap between what was specified and what was built. Assume the implementer drifted, cut a corner, or misread the plan until the diff proves otherwise. Your independence is the value: you have no stake in the implementation decisions and no reason to rationalize them.
You review THE CHANGE against THE PLAN. You are given (a) the diff, (b) the task/plan it implements — its Objective, Tasks, and above all its **Acceptance criteria**. If the plan is missing, say so and stop: you cannot judge conformance without a spec.
## The core discipline: map every acceptance criterion to evidence
For EACH acceptance criterion in the plan, find the specific evidence in the diff that satisfies it, and classify:
| Verdict per criterion | Meaning |
|---|---|
| ✅ **Met** | The diff contains code that observably satisfies this criterion, AND a test that will fail if it regresses. Cite the file:line. |
| ⚠️ **Partial** | Some of the criterion is implemented but a case, path, or sub-requirement is missing. Name what's missing. |
| ❌ **Unmet** | No code in the diff satisfies this criterion. The "dog that didn't bark." |
| 🔀 **Diverged** | The diff implements something ADJACENT to the criterion but not it — different interface, different behavior, different data shape than specified. |
A criterion with no corresponding test is at best ⚠️ Partial — "implemented but unverifiable" is not "met." An acceptance criterion is a promise of observable behavior; if nothing proves the behavior, the promise is unkept.
## What to hunt for (adversarial checklist)
### 1. Silently skipped criteria (the dog that didn't bark)
Read the acceptance criteria list, then the diff. Every criterion with no matching change is a finding. Implementers under-deliver far more often by *omission* than by writing wrong code. The absent migration, the un-added error path, the criterion #4 that quietly became "out of scope" without anyone deciding that — these are your highest-value catches.
### 2. Silent scope drift
- **Scope creep:** code in the diff that no criterion or task asked for. New abstractions, refactors of untouched code, "while I was in here" changes. Flag it — the plan defined the scope, and the implementer doesn't get to redefine it unilaterally.
- **Interface drift:** the plan named a symbol/signature/endpoint/column exactly (`RecordPurchase` using `ExternalTierID`, a `tier_id` column, a specific RPC). The diff uses a different name or shape. Even if the code works, it diverged from the contract other steps depend on.
- **Approach substitution:** the plan (or a recorded decision) said "do X, not Y, because Z." The diff does Y. The implementer re-litigated a settled decision. Flag it with the plan's stated reason.
### 3. Ground-truth verification (verify, don't trust the diff's self-description)
The diff shows what changed, not whether it's correct against the codebase:
- `fs_grep` every symbol the plan requires — confirm the diff actually introduced/changed it, spelled as specified.
- `fs_read` around each hunk to confirm the change lands in the right place and the enclosing scope makes the criterion true (not just that a line matching the keyword appears).
- `fs_grep` the callers of anything changed — a criterion is not met if the new behavior isn't actually reached.
- Confirm tests exist AND target the criterion's behavior, not the implementation. A tautological test (`assert x.is_empty() || !x.is_empty()`) counts as no test.
### 4. Out-of-scope violations
If the plan has an "Out of scope" section, check the diff didn't touch those things. Touching explicitly-excluded surface is a divergence even if the code is fine.
### 5. Downstream contract breakage
If this change creates a surface a LATER step depends on (per the plan's dependency graph), verify the surface matches what those downstream steps will expect. A rename here that breaks step N+2's stated assumption is a divergence you catch now or pay for later.
## Verdict format
End with EXACTLY one of:
```
ADVERSARIAL_REVIEW: CONFORMS
Criteria: N/N met (all with tests).
<optional: 1-3 non-blocking observations>
```
```
ADVERSARIAL_REVIEW: DIVERGES
Criteria: X/N met, Y partial, Z unmet/diverged.
Complaints:
1. Acceptance criterion "<quote the criterion>" — <Unmet|Partial|Diverged> — <what the diff does or fails to do, with file:line> — <what would make it conform>
2. Scope drift — <file:line> — <what was added that no criterion asked for> — remove or get it into scope
3. ...
```
Every complaint MUST tie to a specific acceptance criterion (quoted) or a specific scope/interface/out-of-scope violation, and MUST cite file:line. "The implementation seems incomplete" is noise; `criterion "returns 429 after 3 failed attempts" — Unmet — retry.go has no attempt counter; the loop retries forever (retry.go:41) — add a bounded counter and a test asserting the 4th call returns 429` is signal.
## Scope discipline (what you are NOT)
- You are NOT the code-quality reviewer. Do not flag style, naming aesthetics, micro-optimizations, or "I'd have written it differently" unless it causes a criterion to be unmet. The `code-review` skill owns quality; you own conformance. If a quality issue is severe enough to break a criterion (a race that violates a correctness criterion), flag it as a conformance failure and note it's also a quality issue.
- You do NOT rewrite the code or the plan. You produce a verdict and complaints; the implementer owns the fix.
- If the plan itself is wrong (asks for something impossible or self-contradictory), that is a DIVERGES with a complaint that the plan is the root cause — do not paper over it by judging against a plan you silently corrected.
- Three decisive divergences beat fifteen weak ones. If every criterion is a nitpick, the change probably CONFORMS — say so.
## Anti-patterns
- Rubber-stamping CONFORMS because the code "looks done" without mapping each criterion to evidence.
- Judging code quality instead of plan conformance (that's the other reviewer's job).
- Accepting a criterion as met with no test proving it.
- Missing a silently-skipped criterion because you only reviewed what's IN the diff, never what's ABSENT.
- Complaints with no criterion reference and no file:line.
+4
View File
@@ -16,6 +16,10 @@ evidence yourself — never ask the user to run commands and paste output back.
5. **State each hypothesis in one line before testing it.** Pivot openly when disproved. 5. **State each hypothesis in one line before testing it.** Pivot openly when disproved.
6. **Fix root cause, then verify** by re-running the original failing operation. No verification, no fix. 6. **Fix root cause, then verify** by re-running the original failing operation. No verification, no fix.
## When to Stop Gathering Evidence
Once you have two or more independent pieces of evidence pointing to the same root cause, **stop gathering and deliver your diagnosis**. Do not add more verification steps to verify your verification. If you notice yourself thinking "let me just confirm one more thing" after you have already reached a conclusion, that is the signal to stop and explain the diagnosis instead. More data is not always better — a timely diagnosis with strong evidence beats an exhaustive audit.
## Command Discipline ## Command Discipline
- Non-interactive and bounded, always: `--no-pager`, `-n`/`--since` on logs, `timeout 10` on anything that might - Non-interactive and bounded, always: `--no-pager`, `-n`/`--since` on logs, `timeout 10` on anything that might
+2 -1
View File
@@ -10,7 +10,8 @@ Use IWE tools when the task involves a corpus of markdown documents: plan reposi
Do NOT use IWE tools for: Do NOT use IWE tools for:
- **Agent memory** (`.coyote/memory/`, `COYOTE.md`) — use the `memory__*` tools; they own the index conventions there. - **Agent memory** (`.coyote/memory/`) — use the `memory__*` tools; they own the index conventions there.
- **Workspace instructions** (`COYOTE.md`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`) — human-curated and read-only; never edit them with IWE write tools.
- **Semantic/similarity search over documents** — that is RAG's job. IWE search is fuzzy title/key matching plus structural traversal, not embeddings. - **Semantic/similarity search over documents** — that is RAG's job. IWE search is fuzzy title/key matching plus structural traversal, not embeddings.
- **Source code** — IWE only understands markdown. - **Source code** — IWE only understands markdown.
+9
View File
@@ -7,12 +7,15 @@
# - <agent-name>_TOP_P # - <agent-name>_TOP_P
# - <agent-name>_GLOBAL_TOOLS (as a JSON string array) # - <agent-name>_GLOBAL_TOOLS (as a JSON string array)
# - <agent-name>_MCP_SERVERS (as a JSON string array) # - <agent-name>_MCP_SERVERS (as a JSON string array)
# - <agent-name>_SPAWNABLE_AGENTS (as a JSON string array; see spawnable_agents below)
# - <agent-name>_AGENT_SESSION # - <agent-name>_AGENT_SESSION
# - <agent-name>_VARIABLES (as JSON array of key-value pairs; e.g. '[{"name": "username", "value": "alex"}]') # - <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 model: openai:gpt-4o # Specify the LLM to use
temperature: null # Set default temperature parameter, range (0, 1) 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 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 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 name: <agent-name> # Name of the agent, used in the UI and logs
description: <description> # Description of the agent, used in the UI description: <description> # Description of the agent, used in the UI
@@ -30,6 +33,12 @@ continuation_prompt: null # Custom prompt used when auto-continuing (opti
# Enable this agent to spawn and manage child agents in parallel. # Enable this agent to spawn and manage child agents in parallel.
# See https://github.com/Dark-Alex-17/coyote/wiki/Agents for detailed documentation. # 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_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_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 inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt
+55 -5
View File
@@ -2,6 +2,8 @@
model: openai:gpt-4o # Specify the LLM to use model: openai:gpt-4o # Specify the LLM to use
temperature: null # Set default temperature parameter (0, 1) 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 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 active model declares reasoning_levels. See the Clients docs.
# ---- Behavior ---- # ---- Behavior ----
stream: true # Controls whether to use the stream-style APIs when querying for completions from LLM clients. stream: true # Controls whether to use the stream-style APIs when querying for completions from LLM clients.
@@ -18,6 +20,7 @@ agent_session: null # Set a session to use when starting an agent (
# ---- Appearance ---- # ---- Appearance ----
highlight: true # Controls syntax highlighting 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
light_theme: false # Activates a light color theme when true. env: COYOTE_LIGHT_THEME light_theme: false # Activates a light color theme when true. env: COYOTE_LIGHT_THEME
# ---- Miscellaneous ---- # ---- Miscellaneous ----
@@ -31,7 +34,7 @@ sync_models_url: > # URL to sync model changes from
left_prompt: left_prompt:
'{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} ' '{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} '
right_prompt: right_prompt:
'{color.purple}{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}' '{color.cyan}{?reasoning_effort [{reasoning_effort}] }{color.purple}{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}'
# ---- Vault ---- # ---- Vault ----
# See the [Vault documentation](https://github.com/Dark-Alex-17/coyote/wiki/Vault) for more information on the Coyote vault. # See the [Vault documentation](https://github.com/Dark-Alex-17/coyote/wiki/Vault) for more information on the Coyote vault.
@@ -134,6 +137,14 @@ enabled_mcp_servers: null # Which MCP servers to enable by default.
# - slack # - slack
# Example (comma-separated form): # Example (comma-separated form):
# enabled_mcp_servers: github,slack,ddg-search # enabled_mcp_servers: github,slack,ddg-search
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:
# 1. .coyote/mcp.json
# 2. .coyote/.mcp.json (Claude-style file name)
# 3. .mcp.json (project root; Claude Code convention)
# Workspace entries shadow global ones on name collision.
# Set to true (or pass --no-workspace-mcp) to skip this entirely.
# ---- Skills ---- # ---- Skills ----
# Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation. # Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation.
@@ -179,8 +190,8 @@ summary_context_prompt: > # The text prompt used for including the summar
# ---- Memory ---- # ---- Memory ----
# See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information. # See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information.
# Memory is opt-in by workspace presence (a `COYOTE.md` or `.coyote/memory/MEMORY.md`) # Memory is opt-in by workspace presence (`.coyote/memory/MEMORY.md`) and global
# and global presence (`<config_dir>/memory/MEMORY.md`). Set `memory: false` to disable # presence (`<config_dir>/memory/MEMORY.md`). Set `memory: false` to disable
# even when memory files exist. The cascade is: agent > session > role > app. # even when memory files exist. The cascade is: agent > session > role > app.
# Bootstrap with `coyote --init-memory [global|workspace]` to create the marker file # Bootstrap with `coyote --init-memory [global|workspace]` to create the marker file
# the LLM needs before it will write any memory. # the LLM needs before it will write any memory.
@@ -190,6 +201,18 @@ memory_cap_with_tools: null # Char cap for injected memory when function ca
memory_cap_without_tools: null # Char cap when function calling is unavailable (default: 12000). memory_cap_without_tools: null # Char cap when function calling is unavailable (default: 12000).
# Indexes plus drill file bodies are injected up to this cap. # 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 ---- # ---- RAG ----
# See the [RAG Docs](https://github.com/Dark-Alex-17/coyote/wiki/RAG) for more details. # 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_embedding_model: null # Specifies the embedding model used for context retrieval
@@ -199,7 +222,7 @@ rag_chunk_size: null # Defines the size of chunks for document proce
rag_chunk_overlap: null # Defines the overlap between chunks rag_chunk_overlap: null # Defines the overlap between chunks
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_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_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 (1 = direct neighbors; increase for denser graphs) 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)
# Defines the query structure using variables like __CONTEXT__, __SOURCES__, and __INPUT__ to tailor searches to specific needs # Defines the query structure using variables like __CONTEXT__, __SOURCES__, and __INPUT__ to tailor searches to specific needs
rag_template: | rag_template: |
Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags) Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags)
@@ -326,11 +349,38 @@ clients:
api_base: https://api.mistral.ai/v1 api_base: https://api.mistral.ai/v1
api_key: '{{MISTRAL_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{MISTRAL_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
# See https://docs.x.ai/docs # See https://docs.x.ai/docs - OAuth via SuperGrok / X Premium+ subscription
- type: openai-compatible - type: openai-compatible
name: xai name: xai
api_base: https://api.x.ai/v1 api_base: https://api.x.ai/v1
api_key: '{{XAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault api_key: '{{XAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key
# Authenticate with `coyote --authenticate` or `.authenticate` in the REPL
# Note: Oauth requires SuperGrok/X Premium+ subscription
# Example: private OpenAI-compatible gateway with client_credentials OAuth
# - type: openai-compatible
# name: acme-gateway
# api_base: https://gateway.acme.com/v1
# auth: oauth
# oauth:
# client_id: '{{ACME_CLIENT_ID}}'
# client_secret: '{{ACME_CLIENT_SECRET}}'
# token_url: https://auth.acme.com/oauth/token
# scopes: [openai.chat]
# flow: client_credentials
# Example: OAuth via Device Authorization Grant (RFC 8628 — for CLIs like Moonshot's kimi-code, MiniMax mmx, etc.)
# - type: openai-compatible
# name: moonshot
# api_base: https://api.kimi.com/coding/v1
# auth: oauth
# oauth:
# client_id: '{{MOONSHOT_CLIENT_ID}}'
# device_authorization_url: https://auth.kimi.com/api/oauth/device_authorization
# token_url: https://auth.kimi.com/api/oauth/token
# flow: device_code
# # use_pkce_in_device_flow: true # enable if your provider requires PKCE with device flow
# See https://docs.ai21.com/docs/overview # See https://docs.ai21.com/docs/overview
- type: openai-compatible - type: openai-compatible
+2
View File
@@ -8,6 +8,8 @@ name: <role-name> # The name of the role
model: openai:gpt-4o # The model to use for this role model: openai:gpt-4o # The model to use for this role
temperature: 0.2 # The temperature to use for this role when querying the model temperature: 0.2 # The temperature to use for this role when querying the model
top_p: 0 # The top_p to use for this role when querying the model top_p: 0 # The top_p to use for this role when querying the model
reasoning_effort: null # Reasoning effort level for models that support it (e.g. low, medium, high).
# Only valid when the role's model declares reasoning_levels.
enabled_tools: # Tools to enable for this role. Accepts a YAML list (preferred) enabled_tools: # Tools to enable for this role. Accepts a YAML list (preferred)
- fs_ls # or a comma-separated string (e.g. `enabled_tools: fs_ls,fs_cat`). - fs_ls # or a comma-separated string (e.g. `enabled_tools: fs_ls,fs_cat`).
- fs_cat # Use `all` to enable every visible tool. - fs_cat # Use `all` to enable every visible tool.
+4 -1
View File
@@ -33,6 +33,8 @@ version: "1.0" # Graph schema version. Only "1.0" is accepte
model: claude:claude-sonnet-4-6 # Default model for `llm` nodes that don't override it model: claude:claude-sonnet-4-6 # Default model for `llm` nodes that don't override it
temperature: 0.0 # Default sampling temperature for `llm` nodes temperature: 0.0 # Default sampling temperature for `llm` nodes
top_p: null # Default sampling top-p for `llm` nodes 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.
global_tools: # Tool universe an `llm` node's `tools:` whitelist draws from global_tools: # Tool universe an `llm` node's `tools:` whitelist draws from
- web_search_coyote.sh - web_search_coyote.sh
@@ -227,7 +229,7 @@ nodes:
reranker_model: null # Optional reranker for hybrid-search results reranker_model: null # Optional reranker for hybrid-search results
extractor_model: null # Optional chat model for graph-based entity/relationship extraction; enables graph RAG signal when set extractor_model: null # Optional chat model for graph-based entity/relationship extraction; enables graph RAG signal when set
extractor_prompt: null # Optional custom extraction prompt; must contain __CHUNK__ placeholder; uses built-in prompt when null extractor_prompt: null # Optional custom extraction prompt; must contain __CHUNK__ placeholder; uses built-in prompt when null
graph_hops: 1 # Graph expansion depth at query time (1 = direct neighbors; increase for denser knowledge graphs) graph_hops: 1 # Graph expansion depth at query time (0 = seed nodes only; 1 = direct neighbors; increase for denser knowledge graphs)
batch_size: 100 # Optional embedding-request batch size batch_size: 100 # Optional embedding-request batch size
state_updates: # {{output}} = { context: <str>, sources: [<path>, ...] } state_updates: # {{output}} = { context: <str>, sources: [<path>, ...] }
context: "{{output.context}}" # writes `context` -> `reducers.context = concat` context: "{{output.context}}" # writes `context` -> `reducers.context = concat`
@@ -394,6 +396,7 @@ nodes:
- mcp:ddg-search # `mcp:<server>` includes that server's functions - mcp:ddg-search # `mcp:<server>` includes that server's functions
model: claude:claude-haiku-4-5 # Optional per-node model override model: claude:claude-haiku-4-5 # Optional per-node model override
temperature: 0.3 # Optional per-node sampling override temperature: 0.3 # Optional per-node sampling override
reasoning_effort: null # Optional per-node reasoning effort override (e.g. low, medium, high)
max_attempts: 2 # Retry count on transient errors only. Default 1. max_attempts: 2 # Retry count on transient errors only. Default 1.
max_iterations: 10 # Tool-call-loop turn cap. Default 10. max_iterations: 10 # Tool-call-loop turn cap. Default 10.
fallback: review # Route here if all attempts fail fallback: review # Route here if all attempts fail
+13
View File
@@ -23,3 +23,16 @@ fmt:
[arg('build_type', pattern="debug|release")] [arg('build_type', pattern="debug|release")]
build build_type='debug': build build_type='debug':
@cargo build {{ if build_type == "release" { "--release" } else { "" } }} @cargo build {{ if build_type == "release" { "--release" } else { "" } }}
# Build a multi-platform Docker image (linux/amd64 + linux/arm64).
# Requires an active buildx builder with multi-platform support and a registry login.
# version: must match an existing GitHub release tag (e.g. 0.7.4)
# image: registry/image name to push to (default: darkalex17/coyote)
[group: 'build']
docker-build version image='darkalex17/coyote':
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg COYOTE_VERSION={{ version }} \
--tag {{ image }}:{{ version }} \
--tag {{ image }}:latest \
.
+348 -2
View File
@@ -3,6 +3,33 @@
# - https://platform.openai.com/docs/api-reference/chat # - https://platform.openai.com/docs/api-reference/chat
- provider: openai - provider: openai
models: models:
- name: gpt-5.6-sol
max_input_tokens: 1050000
max_output_tokens: 128000
input_price: 5
output_price: 30
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh, max]
default_reasoning_effort: medium
- name: gpt-5.6-terra
max_input_tokens: 1050000
max_output_tokens: 128000
input_price: 5
output_price: 30
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh, max]
default_reasoning_effort: medium
- name: gpt-5.6-luna
max_input_tokens: 1050000
max_output_tokens: 128000
input_price: 5
output_price: 30
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh, max]
default_reasoning_effort: medium
- name: gpt-5.5 - name: gpt-5.5
max_input_tokens: 1050000 max_input_tokens: 1050000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -10,6 +37,8 @@
output_price: 30 output_price: 30
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: medium
- name: gpt-5.5-pro - name: gpt-5.5-pro
max_input_tokens: 1050000 max_input_tokens: 1050000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -17,6 +46,8 @@
output_price: 180 output_price: 180
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [medium, high, xhigh]
default_reasoning_effort: high
- name: gpt-5.4 - name: gpt-5.4
max_input_tokens: 1050000 max_input_tokens: 1050000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -24,6 +55,8 @@
output_price: 15 output_price: 15
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: none
- name: gpt-5.4-pro - name: gpt-5.4-pro
max_input_tokens: 1050000 max_input_tokens: 1050000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -31,6 +64,8 @@
output_price: 180 output_price: 180
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [medium, high, xhigh]
default_reasoning_effort: medium
- name: gpt-5.4-mini - name: gpt-5.4-mini
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -38,6 +73,8 @@
output_price: 4.5 output_price: 4.5
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: none
- name: gpt-5.4-nano - name: gpt-5.4-nano
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -45,6 +82,8 @@
output_price: 1.25 output_price: 1.25
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: none
- name: gpt-5.3-codex - name: gpt-5.3-codex
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -52,6 +91,8 @@
output_price: 14 output_price: 14
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh]
default_reasoning_effort: medium
- name: chat-latest - name: chat-latest
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -66,6 +107,17 @@
output_price: 14 output_price: 14
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: none
- name: gpt-5.2-pro
max_input_tokens: 400000
max_output_tokens: 128000
input_price: 21
output_price: 168
supports_vision: true
supports_function_calling: true
reasoning_levels: [medium, high, xhigh]
default_reasoning_effort: medium
- name: gpt-5.1 - name: gpt-5.1
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -73,6 +125,8 @@
output_price: 10 output_price: 10
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [none, low, medium, high]
default_reasoning_effort: none
- name: gpt-5.1-chat-latest - name: gpt-5.1-chat-latest
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -80,6 +134,8 @@
output_price: 10 output_price: 10
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [none, low, medium, high]
default_reasoning_effort: none
- name: gpt-5 - name: gpt-5
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -87,6 +143,8 @@
output_price: 10 output_price: 10
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: medium
- name: gpt-5-chat-latest - name: gpt-5-chat-latest
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -94,6 +152,8 @@
output_price: 10 output_price: 10
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: medium
- name: gpt-5-mini - name: gpt-5-mini
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -151,6 +211,8 @@
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
system_prompt_prefix: Formatting re-enabled system_prompt_prefix: Formatting re-enabled
reasoning_levels: [low, medium, high]
default_reasoning_effort: medium
patch: patch:
body: body:
max_tokens: null max_tokens: null
@@ -258,24 +320,38 @@
# - https://ai.google.dev/api/rest/v1beta/models/streamGenerateContent # - https://ai.google.dev/api/rest/v1beta/models/streamGenerateContent
- provider: gemini - provider: gemini
models: models:
- name: gemini-3.6-flash
max_input_tokens: 1048576
max_output_tokens: 65536
input_price: 1.5
output_price: 7.5
supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_level: medium
- name: gemini-3.5-flash - name: gemini-3.5-flash
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65536 max_output_tokens: 65536
input_price: 0.2 input_price: 0.2
output_price: 1.5 output_price: 1.5
supports_function_calling: true supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: medium
- name: gemini-3-flash-preview - name: gemini-3-flash-preview
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65536 max_output_tokens: 65536
input_price: 0.2 input_price: 0.2
output_price: 1.5 output_price: 1.5
supports_function_calling: true supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: high
- name: gemini-3.1-flash-lite - name: gemini-3.1-flash-lite
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65536 max_output_tokens: 65536
input_price: 0.2 input_price: 0.2
output_price: 1.5 output_price: 1.5
supports_function_calling: true supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: minimal
- name: gemini-3.1-pro-preview - name: gemini-3.1-pro-preview
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65535 max_output_tokens: 65535
@@ -283,6 +359,8 @@
output_price: 2.5 output_price: 2.5
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: high
- name: gemini-2.5-flash - name: gemini-2.5-flash
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65536 max_output_tokens: 65536
@@ -297,6 +375,8 @@
output_price: 0 output_price: 0
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: high
- name: gemini-2.5-flash-lite - name: gemini-2.5-flash-lite
max_input_tokens: 1000000 max_input_tokens: 1000000
max_output_tokens: 64000 max_output_tokens: 64000
@@ -308,10 +388,14 @@
max_input_tokens: 1048576 max_input_tokens: 1048576
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, high]
default_reasoning_level: high
- name: gemini-3-flash-preview - name: gemini-3-flash-preview
max_input_tokens: 1048576 max_input_tokens: 1048576
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_level: high
- name: gemma-3-27b-it - name: gemma-3-27b-it
max_input_tokens: 131072 max_input_tokens: 131072
max_output_tokens: 8192 max_output_tokens: 8192
@@ -337,6 +421,8 @@
output_price: 50 output_price: 50
supports_function_calling: true supports_function_calling: true
supports_vision: true supports_vision: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-opus-4-8 - name: claude-opus-4-8
max_input_tokens: 1000000 max_input_tokens: 1000000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -345,6 +431,8 @@
output_price: 25 output_price: 25
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-opus-4-7 - name: claude-opus-4-7
max_input_tokens: 1000000 max_input_tokens: 1000000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -353,6 +441,8 @@
output_price: 25 output_price: 25
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-opus-4-6 - name: claude-opus-4-6
max_input_tokens: 200000 max_input_tokens: 200000
max_output_tokens: 8192 max_output_tokens: 8192
@@ -361,6 +451,8 @@
output_price: 25 output_price: 25
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, max]
default_reasoning_effort: high
- name: claude-opus-4-6:thinking - name: claude-opus-4-6:thinking
real_name: claude-opus-4-6 real_name: claude-opus-4-6
max_input_tokens: 200000 max_input_tokens: 200000
@@ -385,6 +477,8 @@
output_price: 15 output_price: 15
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-sonnet-4-6 - name: claude-sonnet-4-6
max_input_tokens: 200000 max_input_tokens: 200000
max_output_tokens: 8192 max_output_tokens: 8192
@@ -393,6 +487,8 @@
output_price: 15 output_price: 15
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, max]
default_reasoning_effort: high
- name: claude-sonnet-4-6:thinking - name: claude-sonnet-4-6:thinking
real_name: claude-sonnet-4-6 real_name: claude-sonnet-4-6
max_input_tokens: 200000 max_input_tokens: 200000
@@ -716,14 +812,65 @@
# - https://docs.x.ai/docs/models # - https://docs.x.ai/docs/models
# - https://docs.x.ai/docs/api-reference#chat-completions # - https://docs.x.ai/docs/api-reference#chat-completions
- provider: xai - provider: xai
oauth:
client_id: b1a00492-073a-47ea-816f-4c329264a828
authorize_url: https://auth.x.ai/oauth2/authorize
token_url: https://auth.x.ai/oauth2/token
scopes:
- openid
- profile
- email
- offline_access
- grok-cli:access
- api:access
redirect_port: 56121
flow: pkce
token_request_format: form_url_encoded
extra_authorize_params:
plan: generic
referrer: coyote
echo_pkce_in_token_exchange: true
models: models:
- name: grok-4.5
input_price: 2
output_price: 6
max_input_tokens: 256000
supports_function_calling: true
- name: grok-build-0.1
input_price: 1
output_price: 2
max_input_tokens: 256000
supports_function_calling: true
- name: grok-4.3
input_price: 1.25
output_price: 2.5
max_input_tokens: 1000000
supports_function_calling: true
- name: grok-4.20
real_name: grok-4.20-multi-agent-0309
input_price: 1.25
output_price: 2.5
max_input_tokens: 1000000
supports_function_calling: true
- name: grok-4.20-reasoning
real_name: grok-4.20-0309-reasoning
input_price: 1.25
output_price: 2.5
max_input_tokens: 1000000
supports_function_calling: true
- name: grok-4.20-non-reasoning
real_name: grok-4.20-0309-non-reasoning
input_price: 1.25
output_price: 2.5
max_input_tokens: 1000000
supports_function_calling: true
- name: grok-4-1-fast-non-reasoning - name: grok-4-1-fast-non-reasoning
max_input_tokens: 2000000 max_input_tokens: 1000000
input_price: 0.2 input_price: 0.2
output_price: 0.5 output_price: 0.5
supports_function_calling: true supports_function_calling: true
- name: grok-4-1-fast-reasoning - name: grok-4-1-fast-reasoning
max_input_tokens: 2000000 max_input_tokens: 1000000
input_price: 0.2 input_price: 0.2
output_price: 0.5 output_price: 0.5
supports_function_calling: true supports_function_calling: true
@@ -835,18 +982,24 @@
input_price: 0.2 input_price: 0.2
output_price: 1.5 output_price: 1.5
supports_function_calling: true supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: medium
- name: gemini-3-flash-preview - name: gemini-3-flash-preview
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65536 max_output_tokens: 65536
input_price: 0.2 input_price: 0.2
output_price: 1.5 output_price: 1.5
supports_function_calling: true supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: high
- name: gemini-3.1-flash-lite - name: gemini-3.1-flash-lite
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65536 max_output_tokens: 65536
input_price: 0.2 input_price: 0.2
output_price: 1.5 output_price: 1.5
supports_function_calling: true supports_function_calling: true
reasoning_levels: [minimal, high]
default_reasoning_effort: minimal
- name: gemini-3.1-pro-preview - name: gemini-3.1-pro-preview
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65536 max_output_tokens: 65536
@@ -854,6 +1007,8 @@
output_price: 12 output_price: 12
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: high
- name: gemini-2.5-flash - name: gemini-2.5-flash
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65535 max_output_tokens: 65535
@@ -861,6 +1016,8 @@
output_price: 2.5 output_price: 2.5
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: medium
- name: gemini-2.5-pro - name: gemini-2.5-pro
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65536 max_output_tokens: 65536
@@ -868,6 +1025,8 @@
output_price: 10 output_price: 10
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: high
- name: gemini-2.5-flash-lite - name: gemini-2.5-flash-lite
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65536 max_output_tokens: 65536
@@ -879,10 +1038,14 @@
max_input_tokens: 1048576 max_input_tokens: 1048576
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, high]
default_reasoning_effort: high
- name: gemini-3-flash-preview - name: gemini-3-flash-preview
max_input_tokens: 1048576 max_input_tokens: 1048576
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: high
- name: claude-fable-5 - name: claude-fable-5
max_input_tokens: 1000000 max_input_tokens: 1000000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -891,6 +1054,8 @@
output_price: 50 output_price: 50
supports_function_calling: true supports_function_calling: true
supports_vision: true supports_vision: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-opus-4-8 - name: claude-opus-4-8
max_input_tokens: 1000000 max_input_tokens: 1000000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -899,6 +1064,8 @@
output_price: 25 output_price: 25
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-opus-4-7 - name: claude-opus-4-7
max_input_tokens: 1000000 max_input_tokens: 1000000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -907,6 +1074,8 @@
output_price: 25 output_price: 25
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-opus-4-6 - name: claude-opus-4-6
max_input_tokens: 200000 max_input_tokens: 200000
max_output_tokens: 8192 max_output_tokens: 8192
@@ -938,6 +1107,8 @@
output_price: 15 output_price: 15
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: claude-sonnet-4-6 - name: claude-sonnet-4-6
max_input_tokens: 200000 max_input_tokens: 200000
max_output_tokens: 8192 max_output_tokens: 8192
@@ -946,6 +1117,8 @@
output_price: 15 output_price: 15
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, max]
default_reasoning_effort: high
- name: claude-sonnet-4-6:thinking - name: claude-sonnet-4-6:thinking
real_name: claude-sonnet-4-6 real_name: claude-sonnet-4-6
max_input_tokens: 200000 max_input_tokens: 200000
@@ -1078,6 +1251,8 @@
output_price: 50 output_price: 50
supports_function_calling: true supports_function_calling: true
supports_vision: true supports_vision: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: us.anthropic.claude-opus-4-8 - name: us.anthropic.claude-opus-4-8
max_input_tokens: 1000000 max_input_tokens: 1000000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1086,6 +1261,8 @@
output_price: 25 output_price: 25
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: us.anthropic.claude-opus-4-7 - name: us.anthropic.claude-opus-4-7
max_input_tokens: 1000000 max_input_tokens: 1000000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1094,6 +1271,8 @@
output_price: 25 output_price: 25
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: us.anthropic.claude-opus-4-6-v1 - name: us.anthropic.claude-opus-4-6-v1
max_input_tokens: 200000 max_input_tokens: 200000
max_output_tokens: 8192 max_output_tokens: 8192
@@ -1102,6 +1281,8 @@
output_price: 25 output_price: 25
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, max]
default_reasoning_effort: high
- name: us.anthropic.claude-opus-4-6-v1:thinking - name: us.anthropic.claude-opus-4-6-v1:thinking
real_name: us.anthropic.claude-opus-4-6-v1 real_name: us.anthropic.claude-opus-4-6-v1
max_input_tokens: 200000 max_input_tokens: 200000
@@ -1127,6 +1308,8 @@
output_price: 15 output_price: 15
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: us.anthropic.claude-sonnet-4-6 - name: us.anthropic.claude-sonnet-4-6
max_input_tokens: 200000 max_input_tokens: 200000
max_output_tokens: 8192 max_output_tokens: 8192
@@ -1135,6 +1318,8 @@
output_price: 15 output_price: 15
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, max]
default_reasoning_effort: high
- name: us.anthropic.claude-sonnet-4-6:thinking - name: us.anthropic.claude-sonnet-4-6:thinking
real_name: us.anthropic.claude-sonnet-4-6 real_name: us.anthropic.claude-sonnet-4-6
max_input_tokens: 200000 max_input_tokens: 200000
@@ -1516,6 +1701,30 @@
# - https://platform.moonshot.cn/docs/api/chat#%E5%85%AC%E5%BC%80%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%9C%B0%E5%9D%80 # - https://platform.moonshot.cn/docs/api/chat#%E5%85%AC%E5%BC%80%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%9C%B0%E5%9D%80
- provider: moonshot - provider: moonshot
models: models:
- name: kimi-k3
max_input_tokens: 1048576
input_price: 3
output_price: 15
supports_vision: true
supports_function_calling: true
- name: kimi-k2.7-code
max_input_tokens: 262144
input_price: 0.95
output_price: 4
supports_vision: true
supports_function_calling: true
- name: kimi-k2.7-code-highspeed
max_input_tokens: 262144
input_price: 1.9
output_price: 8
supports_vision: true
supports_function_calling: true
- name: kimi-k2.6
max_input_tokens: 262144
input_price: 0.95
output_price: 4
supports_vision: true
supports_function_calling: true
- name: kimi-k2.5 - name: kimi-k2.5
max_input_tokens: 262144 max_input_tokens: 262144
input_price: 0.56 input_price: 0.56
@@ -1550,6 +1759,18 @@
# - https://platform.deepseek.com/api-docs/api/create-chat-completion # - https://platform.deepseek.com/api-docs/api/create-chat-completion
- provider: deepseek - provider: deepseek
models: models:
- name: deepseek-v4-pro
max_input_tokens: 1000000
max_output_tokens: 384000
input_price: 0.435
output_price: 0.87
supports_function_calling: true
- name: deepseek-v4-flash
max_input_tokens: 1000000
max_output_tokens: 384000
input_price: 0.14
output_price: 0.28
supports_function_calling: true
- name: deepseek-chat - name: deepseek-chat
max_input_tokens: 64000 max_input_tokens: 64000
max_output_tokens: 8192 max_output_tokens: 8192
@@ -1618,6 +1839,16 @@
# - https://platform.minimaxi.com/document/ChatCompletion%20v2 # - https://platform.minimaxi.com/document/ChatCompletion%20v2
- provider: minimax - provider: minimax
models: models:
- name: minimax-m3
max_input_tokens: 1000000
input_price: 4.2
output_price: 16.8
supports_function_calling: true
- name: minimax-m2.7
max_input_tokens: 204800
input_price: 0.294
output_price: 1.176
supports_function_calling: true
- name: minimax-m2.5 - name: minimax-m2.5
max_input_tokens: 204800 max_input_tokens: 204800
input_price: 0.294 input_price: 0.294
@@ -1644,6 +1875,33 @@
# - https://openrouter.ai/docs/api-reference/chat-completion # - https://openrouter.ai/docs/api-reference/chat-completion
- provider: openrouter - provider: openrouter
models: models:
- name: openai/gpt-5.6-sol
max_input_tokens: 1050000
max_output_tokens: 128000
input_price: 5
output_price: 30
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh, max]
default_reasoning_effort: medium
- name: openai/gpt-5.6-terra
max_input_tokens: 1050000
max_output_tokens: 128000
input_price: 5
output_price: 30
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh, max]
default_reasoning_effort: medium
- name: openai/gpt-5.6-luna
max_input_tokens: 1050000
max_output_tokens: 128000
input_price: 5
output_price: 30
supports_vision: true
supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh, max]
default_reasoning_effort: medium
- name: openai/gpt-5.5 - name: openai/gpt-5.5
max_input_tokens: 1050000 max_input_tokens: 1050000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1651,6 +1909,8 @@
output_price: 30 output_price: 30
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: medium
- name: openai/gpt-5.5-pro - name: openai/gpt-5.5-pro
max_input_tokens: 1050000 max_input_tokens: 1050000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1658,6 +1918,8 @@
output_price: 180 output_price: 180
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [medium, high, xhigh]
default_reasoning_effort: high
- name: openai/gpt-5.4 - name: openai/gpt-5.4
max_input_tokens: 1050000 max_input_tokens: 1050000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1665,6 +1927,8 @@
output_price: 15 output_price: 15
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: none
- name: openai/gpt-5.4-pro - name: openai/gpt-5.4-pro
max_input_tokens: 1050000 max_input_tokens: 1050000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1672,6 +1936,8 @@
output_price: 180 output_price: 180
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [medium, high, xhigh]
default_reasoning_effort: medium
- name: openai/gpt-5.4-mini - name: openai/gpt-5.4-mini
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1679,6 +1945,8 @@
output_price: 4.5 output_price: 4.5
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: none
- name: openai/gpt-5.4-nano - name: openai/gpt-5.4-nano
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1686,6 +1954,8 @@
output_price: 1.25 output_price: 1.25
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: none
- name: openai/gpt-5.3-codex - name: openai/gpt-5.3-codex
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1693,6 +1963,8 @@
output_price: 14 output_price: 14
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh]
default_reasoning_effort: medium
- name: openai/gpt-5.2 - name: openai/gpt-5.2
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1700,6 +1972,17 @@
output_price: 14 output_price: 14
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [none, low, medium, high, xhigh]
default_reasoning_effort: none
- name: openai/gpt-5.2-pro
max_input_tokens: 400000
max_output_tokens: 128000
input_price: 21
output_price: 168
supports_vision: true
supports_function_calling: true
reasoning_levels: [medium, high, xhigh]
default_reasoning_effort: medium
- name: openai/gpt-5 - name: openai/gpt-5
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1707,6 +1990,8 @@
output_price: 10 output_price: 10
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: medium
- name: openai/gpt-5-mini - name: openai/gpt-5-mini
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1744,18 +2029,67 @@
input_price: 0.04 input_price: 0.04
output_price: 0.16 output_price: 0.16
supports_function_calling: true supports_function_calling: true
- name: google/gemini-3.5-flash
max_input_tokens: 1048576
max_output_tokens: 65536
input_price: 0.2
output_price: 1.5
supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: medium
- name: google/gemini-3-flash-preview
max_input_tokens: 1048576
max_output_tokens: 65536
input_price: 0.2
output_price: 1.5
supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: high
- name: google/gemini-3.1-flash-lite
max_input_tokens: 1048576
max_output_tokens: 65536
input_price: 0.2
output_price: 1.5
supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_effort: minimal
- name: google/gemini-3.1-pro-preview
max_input_tokens: 1048576
max_output_tokens: 65535
input_price: 0.3
output_price: 2.5
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: high
- name: google/gemini-3-pro-preview
max_input_tokens: 1048576
supports_vision: true
supports_function_calling: true
reasoning_levels: [low, high]
default_reasoning_level: high
- name: google/gemini-3-flash-preview
max_input_tokens: 1048576
supports_vision: true
supports_function_calling: true
reasoning_levels: [minimal, low, medium, high]
default_reasoning_level: high
- name: google/gemini-2.5-flash - name: google/gemini-2.5-flash
max_input_tokens: 1048576 max_input_tokens: 1048576
input_price: 0.3 input_price: 0.3
output_price: 2.5 output_price: 2.5
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: low
- name: google/gemini-2.5-pro - name: google/gemini-2.5-pro
max_input_tokens: 1048576 max_input_tokens: 1048576
input_price: 1.25 input_price: 1.25
output_price: 10 output_price: 10
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high]
default_reasoning_effort: high
- name: google/gemini-2.5-flash-lite - name: google/gemini-2.5-flash-lite
max_input_tokens: 1048576 max_input_tokens: 1048576
input_price: 0.3 input_price: 0.3
@@ -1785,6 +2119,8 @@
output_price: 50 output_price: 50
supports_function_calling: true supports_function_calling: true
supports_vision: true supports_vision: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: anthropic/claude-opus-4-8 - name: anthropic/claude-opus-4-8
max_input_tokens: 1000000 max_input_tokens: 1000000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1793,6 +2129,8 @@
output_price: 25 output_price: 25
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: anthropic/claude-opus-4-7 - name: anthropic/claude-opus-4-7
max_input_tokens: 1000000 max_input_tokens: 1000000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1801,6 +2139,8 @@
output_price: 25 output_price: 25
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: anthropic/claude-opus-4.6 - name: anthropic/claude-opus-4.6
max_input_tokens: 200000 max_input_tokens: 200000
max_output_tokens: 8192 max_output_tokens: 8192
@@ -1809,6 +2149,8 @@
output_price: 25 output_price: 25
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, max]
default_reasoning_effort: high
- name: anthropic/claude-sonnet-5 - name: anthropic/claude-sonnet-5
max_input_tokens: 1000000 max_input_tokens: 1000000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -1817,6 +2159,8 @@
output_price: 15 output_price: 15
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, xhigh, max]
default_reasoning_effort: high
- name: anthropic/claude-sonnet-4.6 - name: anthropic/claude-sonnet-4.6
max_input_tokens: 200000 max_input_tokens: 200000
max_output_tokens: 8192 max_output_tokens: 8192
@@ -1825,6 +2169,8 @@
output_price: 15 output_price: 15
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
reasoning_levels: [low, medium, high, max]
default_reasoning_effort: high
- name: anthropic/claude-opus-4.5 - name: anthropic/claude-opus-4.5
max_input_tokens: 200000 max_input_tokens: 200000
max_output_tokens: 8192 max_output_tokens: 8192
+164 -109
View File
@@ -43,6 +43,10 @@ use std::io::{Read, stdin};
), ),
)] )]
pub struct Cli { pub struct Cli {
/// Input text
#[arg(trailing_var_arg = true)]
text: Vec<String>,
/// Select a LLM model /// Select a LLM model
#[arg(short, long, add = ArgValueCompleter::new(model_completer))] #[arg(short, long, add = ArgValueCompleter::new(model_completer))]
pub model: Option<String>, pub model: Option<String>,
@@ -52,30 +56,6 @@ pub struct Cli {
/// Select a role /// Select a role
#[arg(short, long, add = ArgValueCompleter::new(role_completer))] #[arg(short, long, add = ArgValueCompleter::new(role_completer))]
pub role: Option<String>, pub role: Option<String>,
/// Start or join a session
#[arg(short = 's', long, add = ArgValueCompleter::new(session_completer))]
pub session: Option<Option<String>>,
/// Ensure the session is empty
#[arg(long)]
pub empty_session: bool,
/// Ensure the new conversation is saved to the session
#[arg(long)]
pub save_session: bool,
/// Start an agent
#[arg(short = 'a', long, add = ArgValueCompleter::new(agent_completer))]
pub agent: Option<String>,
/// Set agent variables
#[arg(long, value_names = ["NAME", "VALUE"], num_args = 2)]
pub agent_variable: Vec<String>,
/// Start a RAG
#[arg(long, add = ArgValueCompleter::new(rag_completer))]
pub rag: Option<String>,
/// Rebuild the RAG to sync document changes
#[arg(long)]
pub rebuild_rag: bool,
/// Execute a macro
#[arg(long = "macro", value_name = "MACRO", add = ArgValueCompleter::new(macro_completer))]
pub macro_name: Option<String>,
/// Execute commands in natural language /// Execute commands in natural language
#[arg(short = 'e', long)] #[arg(short = 'e', long)]
pub execute: bool, pub execute: bool,
@@ -88,113 +68,188 @@ pub struct Cli {
/// Turn off stream mode /// Turn off stream mode
#[arg(short = 'S', long)] #[arg(short = 'S', long)]
pub no_stream: bool, pub no_stream: bool,
/// Disable memory for this invocation /// Render markdown as raw text with syntax highlighting only (skip the rich markdown renderer)
#[arg(long)] #[arg(long)]
pub no_memory: bool, pub raw_markdown: bool,
/// Skip permission prompts by setting AUTO_CONFIRM for all tools (dangerous!)
#[arg(long)]
pub dangerously_skip_permissions: bool,
/// Bootstrap a memory marker so coyote begins loading memory next run
#[arg(long, value_name = "SCOPE", value_enum)]
pub init_memory: Option<MemoryScope>,
/// Display the message without sending it /// Display the message without sending it
#[arg(long)] #[arg(long)]
pub dry_run: bool, pub dry_run: bool,
/// Display information /// Disable loading workspace MCP servers from .coyote/mcp.json, .coyote/.mcp.json, or .mcp.json
#[arg(long)] #[arg(long)]
pub info: bool, pub no_workspace_mcp: bool,
/// Build all configured Bash tool scripts /// Disable memory for this invocation
#[arg(long)] #[arg(long)]
pub build_tools: bool, pub no_memory: bool,
/// Reinstall bundled assets, overwriting any local changes /// Disable loading workspace instructions (COYOTE.md/AGENTS.md/CLAUDE.md/etc.) for this invocation
#[arg(long, value_name = "CATEGORY", value_enum)]
pub install: Option<AssetCategory>,
/// Install assets from a remote git repository (URL may be suffixed with #<ref>)
#[arg(long, value_name = "GIT_URL")]
pub install_from: Option<String>,
/// Restrict --install-from to a single asset category
#[arg(long, value_name = "CATEGORY", value_enum, requires = "install_from")]
pub filter: Option<InstallFilter>,
/// Overwrite all conflicts without prompting (used with --install-from)
#[arg(long, requires = "install_from")]
pub install_force: bool,
/// Sync models updates
#[arg(long)] #[arg(long)]
pub sync_models: bool, pub no_workspace_instructions: bool,
/// List all available chat models /// Override the workspace instructions file chain for this invocation (repeatable, priority order)
#[arg(long, value_name = "NAME")]
pub workspace_instructions_file: Vec<String>,
/// Skip permission prompts by setting AUTO_CONFIRM for all tools (dangerous!)
#[arg(long)] #[arg(long)]
pub list_models: bool, pub dangerously_skip_permissions: bool,
/// List all roles
#[arg(long)] /// Start or join a session
pub list_roles: bool, #[arg(short = 's', long, help_heading = "Session & Memory", add = ArgValueCompleter::new(session_completer))]
/// List all sessions pub session: Option<Option<String>>,
#[arg(long)] /// Ensure the session is empty
pub list_sessions: bool, #[arg(long, help_heading = "Session & Memory")]
/// List all agents pub empty_session: bool,
#[arg(long)] /// Ensure the new conversation is saved to the session
pub list_agents: bool, #[arg(long, help_heading = "Session & Memory")]
/// List all RAGs pub save_session: bool,
#[arg(long)] /// Bootstrap a memory marker so coyote begins loading memory next run
pub list_rags: bool, #[arg(
/// List all macros long,
#[arg(long)] value_name = "SCOPE",
pub list_macros: bool, value_enum,
/// List all installed skills help_heading = "Session & Memory"
#[arg(long)] )]
pub list_skills: bool, pub init_memory: Option<MemoryScope>,
/// Scaffold a COYOTE.md workspace instructions file in the current directory
#[arg(long, help_heading = "Session & Memory")]
pub init_instructions: bool,
/// Pre-load an existing skill into the session (repeatable). If a single /// Pre-load an existing skill into the session (repeatable). If a single
/// `--skill <NAME>` is given and the skill doesn't exist, opens $EDITOR /// `--skill <NAME>` is given and the skill doesn't exist, opens $EDITOR
/// with a scaffold to create it. /// with a scaffold to create it.
#[arg(long, value_name = "NAME")] #[arg(long, value_name = "NAME", help_heading = "Session & Memory")]
pub skill: Vec<String>, pub skill: Vec<String>,
/// Input text
#[arg(trailing_var_arg = true)] /// Start an agent
text: Vec<String>, #[arg(short = 'a', long, help_heading = "Agents, RAG & Macros", add = ArgValueCompleter::new(agent_completer))]
/// Tail logs pub agent: Option<String>,
#[arg(long)] /// Set agent variables
pub tail_logs: bool, #[arg(long, value_names = ["NAME", "VALUE"], num_args = 2, help_heading = "Agents, RAG & Macros")]
/// Disable colored log output pub agent_variable: Vec<String>,
#[arg(long, requires = "tail_logs")] /// Start a RAG
pub disable_log_colors: bool, #[arg(long, help_heading = "Agents, RAG & Macros", add = ArgValueCompleter::new(rag_completer))]
/// Add a secret to the Coyote vault pub rag: Option<String>,
#[arg(long, value_name = "SECRET_NAME", exclusive = true)] /// Rebuild the RAG to sync document changes
pub add_secret: Option<String>, #[arg(long, help_heading = "Agents, RAG & Macros")]
/// Decrypt a secret from the Coyote vault and print the plaintext pub rebuild_rag: bool,
#[arg(long, value_name = "SECRET_NAME", exclusive = true, add = ArgValueCompleter::new(secrets_completer))] /// Execute a macro
pub get_secret: Option<String>, #[arg(long = "macro", value_name = "MACRO", help_heading = "Agents, RAG & Macros", add = ArgValueCompleter::new(macro_completer))]
/// Update an existing secret in the Coyote vault pub macro_name: Option<String>,
#[arg(long, value_name = "SECRET_NAME", exclusive = true, add = ArgValueCompleter::new(secrets_completer))]
pub update_secret: Option<String>, /// List all available chat models
/// Delete a secret from the Coyote vault #[arg(long, help_heading = "List & Discovery")]
#[arg(long, value_name = "SECRET_NAME", exclusive = true, add = ArgValueCompleter::new(secrets_completer))] pub list_models: bool,
pub delete_secret: Option<String>, /// List all roles
/// List all secrets stored in the Coyote vault #[arg(long, help_heading = "List & Discovery")]
#[arg(long, exclusive = true)] pub list_roles: bool,
pub list_secrets: bool, /// List all sessions
/// Authenticate with an LLM provider using OAuth (e.g., --authenticate client_name) #[arg(long, help_heading = "List & Discovery")]
#[arg(long, exclusive = true, value_name = "CLIENT_NAME")] pub list_sessions: bool,
pub authenticate: Option<Option<String>>, /// List all agents
/// Authenticate with an OAuth-protected remote MCP server (e.g., --auth-mcp server_name) #[arg(long, help_heading = "List & Discovery")]
#[arg(long, exclusive = true, value_name = "SERVER_NAME", add = ArgValueCompleter::new(mcp_server_completer))] pub list_agents: bool,
pub auth_mcp: Option<String>, /// List all RAGs
/// Generate static shell completion scripts #[arg(long, help_heading = "List & Discovery")]
#[arg(long, value_name = "SHELL", value_enum)] pub list_rags: bool,
pub completions: Option<ShellCompletion>, /// List all macros
#[arg(long, help_heading = "List & Discovery")]
pub list_macros: bool,
/// List all installed skills
#[arg(long, help_heading = "List & Discovery")]
pub list_skills: bool,
/// Reinstall bundled assets, overwriting any local changes
#[arg(
long,
value_name = "CATEGORY",
value_enum,
help_heading = "Installation & Updates"
)]
pub install: Option<AssetCategory>,
/// Install assets from a remote git repository (URL may be suffixed with #<ref>)
#[arg(long, value_name = "GIT_URL", help_heading = "Installation & Updates")]
pub install_from: Option<String>,
/// Restrict --install-from to a single asset category
#[arg(
long,
value_name = "CATEGORY",
value_enum,
requires = "install_from",
help_heading = "Installation & Updates"
)]
pub filter: Option<InstallFilter>,
/// Overwrite all conflicts without prompting (used with --install-from)
#[arg(
long,
requires = "install_from",
help_heading = "Installation & Updates"
)]
pub install_force: bool,
/// Sync models updates
#[arg(long, help_heading = "Installation & Updates")]
pub sync_models: bool,
/// Update Coyote to the latest release, or to a specific version /// Update Coyote to the latest release, or to a specific version
#[arg(long, value_name = "VERSION")] #[arg(long, value_name = "VERSION", help_heading = "Installation & Updates")]
pub update: Option<Option<String>>, pub update: Option<Option<String>>,
/// With --update, update even if Coyote was installed via a package manager /// With --update, update even if Coyote was installed via a package manager
#[arg(long, requires = "update")] #[arg(long, requires = "update", help_heading = "Installation & Updates")]
pub force: bool, pub force: bool,
/// Add a secret to the Coyote vault
#[arg(
long,
value_name = "SECRET_NAME",
exclusive = true,
help_heading = "Vault & Secrets"
)]
pub add_secret: Option<String>,
/// Decrypt a secret from the Coyote vault and print the plaintext
#[arg(long, value_name = "SECRET_NAME", exclusive = true, help_heading = "Vault & Secrets", add = ArgValueCompleter::new(secrets_completer))]
pub get_secret: Option<String>,
/// Update an existing secret in the Coyote vault
#[arg(long, value_name = "SECRET_NAME", exclusive = true, help_heading = "Vault & Secrets", add = ArgValueCompleter::new(secrets_completer))]
pub update_secret: Option<String>,
/// Delete a secret from the Coyote vault
#[arg(long, value_name = "SECRET_NAME", exclusive = true, help_heading = "Vault & Secrets", add = ArgValueCompleter::new(secrets_completer))]
pub delete_secret: Option<String>,
/// List all secrets stored in the Coyote vault
#[arg(long, exclusive = true, help_heading = "Vault & Secrets")]
pub list_secrets: bool,
/// Authenticate with an LLM provider using OAuth (e.g., --authenticate client_name)
#[arg(
long,
exclusive = true,
value_name = "CLIENT_NAME",
help_heading = "Authentication"
)]
pub authenticate: Option<Option<String>>,
/// Authenticate with an OAuth-protected remote MCP server (e.g., --auth-mcp server_name)
#[arg(long, exclusive = true, value_name = "SERVER_NAME", help_heading = "Authentication", add = ArgValueCompleter::new(mcp_server_completer))]
pub auth_mcp: Option<String>,
/// Launch Coyote inside a Docker sandbox (via `sbx`); name defaults to current directory basename /// Launch Coyote inside a Docker sandbox (via `sbx`); name defaults to current directory basename
#[arg(long, value_name = "NAME")] #[arg(long, value_name = "NAME", help_heading = "Sandbox")]
pub sandbox: Option<Option<String>>, pub sandbox: Option<Option<String>>,
/// Create the sandbox without bootstrapping the host config or vault password file /// Create the sandbox without bootstrapping the host config or vault password file
#[arg(long, requires = "sandbox")] #[arg(long, requires = "sandbox", help_heading = "Sandbox")]
pub fresh: bool, pub fresh: bool,
/// Skip discovery and application of all sbx mixins (user and built-in) /// Skip discovery and application of all sbx mixins (user and built-in)
#[arg(long, requires = "sandbox")] #[arg(long, requires = "sandbox", help_heading = "Sandbox")]
pub no_mixins: bool, pub no_mixins: bool,
/// Display information
#[arg(long, help_heading = "Diagnostics & Tools")]
pub info: bool,
/// Build all configured Bash tool scripts
#[arg(long, help_heading = "Diagnostics & Tools")]
pub build_tools: bool,
/// Tail logs
#[arg(long, help_heading = "Diagnostics & Tools")]
pub tail_logs: bool,
/// Disable colored log output
#[arg(long, requires = "tail_logs", help_heading = "Diagnostics & Tools")]
pub disable_log_colors: bool,
/// Generate static shell completion scripts
#[arg(long, value_name = "SHELL", value_enum, help_heading = "Shell")]
pub completions: Option<ShellCompletion>,
} }
impl Cli { impl Cli {
+2 -2
View File
@@ -50,7 +50,7 @@ fn prepare_chat_completions(
let url = format!( let url = format!(
"{}/openai/deployments/{}/chat/completions?api-version=2024-12-01-preview", "{}/openai/deployments/{}/chat/completions?api-version=2024-12-01-preview",
&api_base, api_base,
self_.model.real_name() self_.model.real_name()
); );
@@ -69,7 +69,7 @@ fn prepare_embeddings(self_: &AzureOpenAIClient, data: &EmbeddingsData) -> Resul
let url = format!( let url = format!(
"{}/openai/deployments/{}/embeddings?api-version=2024-10-21", "{}/openai/deployments/{}/embeddings?api-version=2024-10-21",
&api_base, api_base,
self_.model.real_name() self_.model.real_name()
); );
+10 -1
View File
@@ -325,6 +325,7 @@ fn build_chat_completions_body(data: ChatCompletionsData, model: &Model) -> Resu
mut messages, mut messages,
temperature, temperature,
top_p, top_p,
reasoning_effort,
functions, functions,
stream: _, stream: _,
} = data; } = data;
@@ -396,6 +397,11 @@ fn build_chat_completions_body(data: ChatCompletionsData, model: &Model) -> Resu
})) }))
} }
for tool_result in tool_results { for tool_result in tool_results {
if let Some(round_text) = &tool_result.text {
assistant_parts.push(json!({
"text": round_text,
}))
}
assistant_parts.push(json!({ assistant_parts.push(json!({
"toolUse": { "toolUse": {
"toolUseId": tool_result.call.id, "toolUseId": tool_result.call.id,
@@ -457,6 +463,9 @@ fn build_chat_completions_body(data: ChatCompletionsData, model: &Model) -> Resu
if let Some(v) = top_p { if let Some(v) = top_p {
body["inferenceConfig"]["topP"] = v.into(); body["inferenceConfig"]["topP"] = v.into();
} }
if let Some(v) = reasoning_effort {
body["additionalModelRequestFields"] = json!({ "output_config": { "effort": v } });
}
if let Some(functions) = functions { if let Some(functions) = functions {
let tools: Vec<_> = functions let tools: Vec<_> = functions
.iter() .iter()
@@ -520,7 +529,7 @@ fn extract_chat_completions(data: &Value) -> Result<ChatCompletionsOutput> {
bail!("Invalid response data: {data}"); bail!("Invalid response data: {data}");
} }
let output = ChatCompletionsOutput { text, tool_calls }; let output = ChatCompletionsOutput { text, tool_calls, ..Default::default() };
Ok(output) Ok(output)
} }
+53 -7
View File
@@ -168,12 +168,22 @@ pub async fn claude_chat_completions_streaming(
let mut function_arguments = String::new(); let mut function_arguments = String::new();
let mut function_id = String::new(); let mut function_id = String::new();
let mut reasoning_state = 0; let mut reasoning_state = 0;
let mut thinking_text = String::new();
let mut thinking_signature = String::new();
let handle = |message: SseMessage| -> Result<bool> { let handle = |message: SseMessage| -> Result<bool> {
let data: Value = serde_json::from_str(&message.data)?; let data: Value = serde_json::from_str(&message.data)?;
debug!("stream-data: {data}"); debug!("stream-data: {data}");
if let Some(typ) = data["type"].as_str() { if let Some(typ) = data["type"].as_str() {
match typ { match typ {
"content_block_start" => { "content_block_start" => {
if let (Some("redacted_thinking"), Some(redacted_data)) = (
data["content_block"]["type"].as_str(),
data["content_block"]["data"].as_str(),
) {
handler.thinking_block(ThinkingBlock::RedactedThinking {
data: redacted_data.to_string(),
});
}
if let (Some("tool_use"), Some(name), Some(id)) = ( if let (Some("tool_use"), Some(name), Some(id)) = (
data["content_block"]["type"].as_str(), data["content_block"]["type"].as_str(),
data["content_block"]["name"].as_str(), data["content_block"]["name"].as_str(),
@@ -206,7 +216,10 @@ pub async fn claude_chat_completions_streaming(
handler.text("<think>\n")?; handler.text("<think>\n")?;
reasoning_state = 1; reasoning_state = 1;
} }
thinking_text.push_str(text);
handler.text(text)?; handler.text(text)?;
} else if let Some(signature) = data["delta"]["signature"].as_str() {
thinking_signature.push_str(signature);
} else if let (true, Some(partial_json)) = ( } else if let (true, Some(partial_json)) = (
!function_name.is_empty(), !function_name.is_empty(),
data["delta"]["partial_json"].as_str(), data["delta"]["partial_json"].as_str(),
@@ -218,6 +231,10 @@ pub async fn claude_chat_completions_streaming(
if reasoning_state == 1 { if reasoning_state == 1 {
handler.text("\n</think>\n\n")?; handler.text("\n</think>\n\n")?;
reasoning_state = 0; reasoning_state = 0;
handler.thinking_block(ThinkingBlock::Thinking {
thinking: std::mem::take(&mut thinking_text),
signature: std::mem::take(&mut thinking_signature),
});
} }
if !function_name.is_empty() { if !function_name.is_empty() {
let arguments: Value = if function_arguments.is_empty() { let arguments: Value = if function_arguments.is_empty() {
@@ -251,6 +268,7 @@ pub fn claude_build_chat_completions_body(
mut messages, mut messages,
temperature, temperature,
top_p, top_p,
reasoning_effort,
functions, functions,
stream, stream,
} = data; } = data;
@@ -312,13 +330,25 @@ pub fn claude_build_chat_completions_body(
}) => { }) => {
let mut assistant_parts = vec![]; let mut assistant_parts = vec![];
let mut user_parts = vec![]; let mut user_parts = vec![];
if !text.is_empty() { for (index, tool_result) in tool_results.iter().enumerate() {
assistant_parts.push(json!({ for block in &tool_result.thinking {
"type": "text", assistant_parts.push(json!(block));
"text": text, }
})) let round_text = if index == 0 && !text.is_empty() {
} Some(text.as_str())
for tool_result in tool_results { } else {
tool_result.text.as_deref()
};
if let Some(round_text) = round_text {
let round_text = strip_think_tag(round_text);
let round_text = round_text.trim();
if !round_text.is_empty() {
assistant_parts.push(json!({
"type": "text",
"text": round_text,
}))
}
}
assistant_parts.push(json!({ assistant_parts.push(json!({
"type": "tool_use", "type": "tool_use",
"id": tool_result.call.id, "id": tool_result.call.id,
@@ -369,6 +399,9 @@ pub fn claude_build_chat_completions_body(
if let Some(v) = top_p { if let Some(v) = top_p {
body["top_p"] = v.into(); body["top_p"] = v.into();
} }
if let Some(v) = reasoning_effort {
body["output_config"] = json!({ "effort": v });
}
if stream { if stream {
body["stream"] = true.into(); body["stream"] = true.into();
} }
@@ -399,12 +432,24 @@ pub fn claude_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
let mut text = String::new(); let mut text = String::new();
let mut reasoning = None; let mut reasoning = None;
let mut tool_calls = vec![]; let mut tool_calls = vec![];
let mut thinking = vec![];
if let Some(list) = data["content"].as_array() { if let Some(list) = data["content"].as_array() {
for item in list { for item in list {
match item["type"].as_str() { match item["type"].as_str() {
Some("thinking") => { Some("thinking") => {
if let Some(v) = item["thinking"].as_str() { if let Some(v) = item["thinking"].as_str() {
reasoning = Some(v.to_string()); reasoning = Some(v.to_string());
thinking.push(ThinkingBlock::Thinking {
thinking: v.to_string(),
signature: item["signature"].as_str().unwrap_or_default().to_string(),
});
}
}
Some("redacted_thinking") => {
if let Some(v) = item["data"].as_str() {
thinking.push(ThinkingBlock::RedactedThinking {
data: v.to_string(),
});
} }
} }
Some("text") => { Some("text") => {
@@ -443,6 +488,7 @@ pub fn claude_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
let output = ChatCompletionsOutput { let output = ChatCompletionsOutput {
text: text.to_string(), text: text.to_string(),
tool_calls, tool_calls,
thinking,
}; };
Ok(output) Ok(output)
} }
+2 -2
View File
@@ -25,8 +25,8 @@ impl OAuthProvider for ClaudeOAuthProvider {
"https://console.anthropic.com/oauth/code/callback" "https://console.anthropic.com/oauth/code/callback"
} }
fn scopes(&self) -> &str { fn scopes(&self) -> String {
"org:create_api_key user:profile user:inference" "org:create_api_key user:profile user:inference".to_string()
} }
fn extra_authorize_params(&self) -> Vec<(&str, &str)> { fn extra_authorize_params(&self) -> Vec<(&str, &str)> {
+1 -1
View File
@@ -244,6 +244,6 @@ fn extract_chat_completions(data: &Value) -> Result<ChatCompletionsOutput> {
if text.is_empty() && tool_calls.is_empty() { if text.is_empty() && tool_calls.is_empty() {
bail!("Invalid response data: {data}"); bail!("Invalid response data: {data}");
} }
let output = ChatCompletionsOutput { text, tool_calls }; let output = ChatCompletionsOutput { text, tool_calls, ..Default::default() };
Ok(output) Ok(output)
} }
+30 -6
View File
@@ -286,6 +286,7 @@ pub struct ChatCompletionsData {
pub messages: Vec<Message>, pub messages: Vec<Message>,
pub temperature: Option<f64>, pub temperature: Option<f64>,
pub top_p: Option<f64>, pub top_p: Option<f64>,
pub reasoning_effort: Option<String>,
pub functions: Option<Vec<FunctionDeclaration>>, pub functions: Option<Vec<FunctionDeclaration>>,
pub stream: bool, pub stream: bool,
} }
@@ -294,6 +295,7 @@ pub struct ChatCompletionsData {
pub struct ChatCompletionsOutput { pub struct ChatCompletionsOutput {
pub text: String, pub text: String,
pub tool_calls: Vec<ToolCall>, pub tool_calls: Vec<ToolCall>,
pub thinking: Vec<ThinkingBlock>,
} }
impl ChatCompletionsOutput { impl ChatCompletionsOutput {
@@ -401,9 +403,24 @@ pub async fn create_openai_compatible_client_config(
}; };
config["api_base"] = api_base.into(); config["api_base"] = api_base.into();
let api_key = prompt_input_string("API Key", false, None)?; let has_bundled_oauth = ALL_PROVIDER_MODELS
if !api_key.is_empty() { .iter()
config["api_key"] = api_key.into(); .any(|p| p.provider == client && p.oauth.is_some());
let use_oauth = if has_bundled_oauth {
let choice = Select::new("Authentication method:", vec!["API Key", "OAuth"]).prompt()?;
choice == "OAuth"
} else {
false
};
if use_oauth {
config["auth"] = "oauth".into();
} else {
let api_key = prompt_input_string("API Key", false, None)?;
if !api_key.is_empty() {
config["api_key"] = api_key.into();
}
} }
let model = set_client_models_config(&mut config, &name).await?; let model = set_client_models_config(&mut config, &name).await?;
@@ -434,6 +451,7 @@ pub async fn call_chat_completions(
let ChatCompletionsOutput { let ChatCompletionsOutput {
mut text, mut text,
tool_calls, tool_calls,
thinking,
.. ..
} = ret; } = ret;
if !text.is_empty() { if !text.is_empty() {
@@ -444,7 +462,10 @@ pub async fn call_chat_completions(
ctx.app.config.print_markdown(&text)?; ctx.app.config.print_markdown(&text)?;
} }
} }
let tool_results = eval_tool_calls(ctx, tool_calls).await?; let mut tool_results = eval_tool_calls(ctx, tool_calls).await?;
if let Some(first) = tool_results.first_mut() {
first.thinking = thinking;
}
tool_results tool_results
.iter() .iter()
.for_each(|res| ctx.tool_scope.tool_tracker.record_call(res.call.clone())); .for_each(|res| ctx.tool_scope.tool_tracker.record_call(res.call.clone()));
@@ -478,13 +499,16 @@ pub async fn call_chat_completions_streaming(
render_ret?; render_ret?;
let (text, tool_calls) = handler.take(); let (text, tool_calls, thinking) = handler.take();
match send_ret { match send_ret {
Ok(_) => { Ok(_) => {
if !text.is_empty() && !text.ends_with('\n') { if !text.is_empty() && !text.ends_with('\n') {
println!(); println!();
} }
let tool_results = eval_tool_calls(ctx, tool_calls).await?; let mut tool_results = eval_tool_calls(ctx, tool_calls).await?;
if let Some(first) = tool_results.first_mut() {
first.thinking = thinking;
}
tool_results tool_results
.iter() .iter()
.for_each(|res| ctx.tool_scope.tool_tracker.record_call(res.call.clone())); .for_each(|res| ctx.tool_scope.tool_tracker.record_call(res.call.clone()));
+2 -2
View File
@@ -27,8 +27,8 @@ impl OAuthProvider for GeminiOAuthProvider {
"" ""
} }
fn scopes(&self) -> &str { fn scopes(&self) -> String {
"https://www.googleapis.com/auth/generative-language.peruserquota https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/userinfo.email" "https://www.googleapis.com/auth/generative-language.peruserquota https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/userinfo.email".to_string()
} }
fn client_secret(&self) -> Option<&str> { fn client_secret(&self) -> Option<&str> {
+20 -2
View File
@@ -118,6 +118,9 @@ impl MessageContent {
lines.push(text.clone()) lines.push(text.clone())
} }
for tool_result in tool_results { for tool_result in tool_results {
if let Some(round_text) = &tool_result.text {
lines.push(round_text.clone())
}
let mut parts = vec!["Call".to_string()]; let mut parts = vec!["Call".to_string()];
if let Some((agent_name, functions)) = agent_info if let Some((agent_name, functions)) = agent_info
&& functions.contains(&tool_result.call.name) && functions.contains(&tool_result.call.name)
@@ -185,6 +188,17 @@ pub struct ImageUrl {
pub url: String, pub url: String,
} }
/// An extended-thinking block returned by Anthropic-protocol models.
/// Serialized to match the API wire format (`type: thinking` / `type: redacted_thinking`)
/// so blocks can be replayed verbatim, signature intact, in subsequent
/// tool-loop rounds as the API requires.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ThinkingBlock {
Thinking { thinking: String, signature: String },
RedactedThinking { data: String },
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MessageContentToolCalls { pub struct MessageContentToolCalls {
pub tool_results: Vec<ToolResult>, pub tool_results: Vec<ToolResult>,
@@ -201,9 +215,13 @@ impl MessageContentToolCalls {
} }
} }
pub fn merge(&mut self, tool_results: Vec<ToolResult>, _text: String) { pub fn merge(&mut self, mut tool_results: Vec<ToolResult>, text: String) {
if !text.is_empty()
&& let Some(first) = tool_results.first_mut()
{
first.text = Some(text);
}
self.tool_results.extend(tool_results); self.tool_results.extend(tool_results);
self.text.clear();
self.sequence = true; self.sequence = true;
} }
} }
+1
View File
@@ -4,6 +4,7 @@ mod common;
mod gemini_oauth; mod gemini_oauth;
mod message; mod message;
pub mod oauth; pub mod oauth;
mod openai_compatible_oauth;
mod openai_oauth; mod openai_oauth;
#[macro_use] #[macro_use]
mod macros; mod macros;
+15
View File
@@ -6,6 +6,7 @@ use super::{
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::utils::{estimate_token_length, strip_think_tag}; use crate::utils::{estimate_token_length, strip_think_tag};
use super::oauth::OAuthConfig;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -289,6 +290,14 @@ impl Model {
} }
Ok(()) Ok(())
} }
pub fn reasoning_levels(&self) -> &[String] {
&self.data.reasoning_levels
}
pub fn default_reasoning_effort(&self) -> Option<&str> {
self.data.default_reasoning_effort.as_deref()
}
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -316,6 +325,10 @@ pub struct ModelData {
pub supports_vision: bool, pub supports_vision: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub supports_function_calling: bool, pub supports_function_calling: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reasoning_levels: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_reasoning_effort: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
no_stream: bool, no_stream: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
@@ -345,6 +358,8 @@ impl ModelData {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderModels { pub struct ProviderModels {
pub provider: String, pub provider: String,
#[serde(default)]
pub oauth: Option<OAuthConfig>,
pub models: Vec<ModelData>, pub models: Vec<ModelData>,
} }
+993 -75
View File
File diff suppressed because it is too large Load Diff
+67 -35
View File
@@ -356,6 +356,7 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
messages, messages,
temperature, temperature,
top_p, top_p,
reasoning_effort,
functions, functions,
stream, stream,
} = data; } = data;
@@ -369,7 +370,7 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
match content { match content {
MessageContent::ToolCalls(MessageContentToolCalls { MessageContent::ToolCalls(MessageContentToolCalls {
tool_results, tool_results,
text: _, text,
sequence, sequence,
}) => { }) => {
if !sequence { if !sequence {
@@ -386,9 +387,12 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
}) })
}) })
.collect(); .collect();
let mut messages = vec![ let mut assistant_message =
json!({ "role": MessageRole::Assistant, "tool_calls": tool_calls }), json!({ "role": MessageRole::Assistant, "tool_calls": tool_calls });
]; if !text.is_empty() {
assistant_message["content"] = strip_think_tag(&text).into();
}
let mut messages = vec![assistant_message];
for tool_result in tool_results { for tool_result in tool_results {
messages.push(json!({ messages.push(json!({
"role": "tool", "role": "tool",
@@ -398,21 +402,30 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
} }
messages messages
} else { } else {
tool_results.into_iter().flat_map(|tool_result| { tool_results.into_iter().enumerate().flat_map(|(index, tool_result)| {
let round_text = if index == 0 && !text.is_empty() {
Some(text.clone())
} else {
tool_result.text.clone()
};
let mut assistant_message = json!({
"role": MessageRole::Assistant,
"tool_calls": [
{
"id": tool_result.call.id,
"type": "function",
"function": {
"name": tool_result.call.name,
"arguments": tool_result.call.arguments.to_string(),
},
}
]
});
if let Some(round_text) = round_text {
assistant_message["content"] = strip_think_tag(&round_text).into();
}
vec![ vec![
json!({ assistant_message,
"role": MessageRole::Assistant,
"tool_calls": [
{
"id": tool_result.call.id,
"type": "function",
"function": {
"name": tool_result.call.name,
"arguments": tool_result.call.arguments.to_string(),
},
}
]
}),
json!({ json!({
"role": "tool", "role": "tool",
"content": tool_result.output.to_string(), "content": tool_result.output.to_string(),
@@ -454,6 +467,9 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
if let Some(v) = top_p { if let Some(v) = top_p {
body["top_p"] = v.into(); body["top_p"] = v.into();
} }
if let Some(v) = reasoning_effort {
body["reasoning_effort"] = v.into();
}
if stream { if stream {
body["stream"] = true.into(); body["stream"] = true.into();
} }
@@ -517,7 +533,7 @@ pub fn openai_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
} else { } else {
text.to_string() text.to_string()
}; };
let output = ChatCompletionsOutput { text, tool_calls }; let output = ChatCompletionsOutput { text, tool_calls, ..Default::default() };
Ok(output) Ok(output)
} }
@@ -534,6 +550,7 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) ->
messages, messages,
temperature, temperature,
top_p, top_p,
reasoning_effort,
functions, functions,
stream, stream,
} = data; } = data;
@@ -547,24 +564,36 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) ->
match content { match content {
MessageContent::ToolCalls(MessageContentToolCalls { MessageContent::ToolCalls(MessageContentToolCalls {
tool_results, tool_results,
text: _, text,
sequence: _, sequence: _,
}) => tool_results }) => tool_results
.into_iter() .into_iter()
.flat_map(|tool_result| { .enumerate()
vec![ .flat_map(|(index, tool_result)| {
json!({ let round_text = if index == 0 && !text.is_empty() {
"type": "function_call", Some(text.clone())
"call_id": tool_result.call.id, } else {
"name": tool_result.call.name, tool_result.text.clone()
"arguments": tool_result.call.arguments.to_string(), };
}), let mut items = vec![];
json!({ if let Some(round_text) = round_text {
"type": "function_call_output", items.push(json!({
"call_id": tool_result.call.id, "role": MessageRole::Assistant,
"output": tool_result.output.to_string(), "content": strip_think_tag(&round_text),
}), }));
] }
items.push(json!({
"type": "function_call",
"call_id": tool_result.call.id,
"name": tool_result.call.name,
"arguments": tool_result.call.arguments.to_string(),
}));
items.push(json!({
"type": "function_call_output",
"call_id": tool_result.call.id,
"output": tool_result.output.to_string(),
}));
items
}) })
.collect(), .collect(),
MessageContent::Text(text) if role.is_assistant() && i != messages_len - 1 => { MessageContent::Text(text) if role.is_assistant() && i != messages_len - 1 => {
@@ -590,6 +619,9 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) ->
if let Some(v) = top_p { if let Some(v) = top_p {
body["top_p"] = v.into(); body["top_p"] = v.into();
} }
if let Some(v) = reasoning_effort {
body["reasoning"] = json!({ "effort": v });
}
if stream { if stream {
body["stream"] = true.into(); body["stream"] = true.into();
} }
@@ -664,7 +696,7 @@ pub fn openai_extract_responses(data: &Value) -> Result<ChatCompletionsOutput> {
if text.is_empty() && tool_calls.is_empty() { if text.is_empty() && tool_calls.is_empty() {
bail!("Invalid response data: {data}"); bail!("Invalid response data: {data}");
} }
Ok(ChatCompletionsOutput { text, tool_calls }) Ok(ChatCompletionsOutput { text, tool_calls, ..Default::default() })
} }
pub async fn openai_responses_streaming( pub async fn openai_responses_streaming(
+126 -40
View File
@@ -1,16 +1,21 @@
use super::access_token::get_access_token;
use super::oauth;
use super::openai::*; use super::openai::*;
use super::*; use super::*;
use anyhow::{Context, Result}; use anyhow::{Context, Result, anyhow, bail};
use reqwest::RequestBuilder; use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use oauth::OAuthConfig;
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct OpenAICompatibleConfig { pub struct OpenAICompatibleConfig {
pub name: Option<String>, pub name: Option<String>,
pub api_base: Option<String>, pub api_base: Option<String>,
pub api_key: Option<String>, pub api_key: Option<String>,
pub auth: Option<String>,
pub oauth: Option<Box<OAuthConfig>>,
#[serde(default)] #[serde(default)]
pub models: Vec<ModelData>, pub models: Vec<ModelData>,
pub patch: Option<RequestPatch>, pub patch: Option<RequestPatch>,
@@ -24,76 +29,157 @@ impl OpenAICompatibleClient {
create_client_config!([]); create_client_config!([]);
} }
impl_client_trait!( #[async_trait::async_trait]
OpenAICompatibleClient, impl Client for OpenAICompatibleClient {
( client_common_fns!();
prepare_chat_completions,
openai_chat_completions,
openai_chat_completions_streaming
),
(prepare_embeddings, openai_embeddings),
(prepare_rerank, generic_rerank),
);
fn prepare_chat_completions( fn supports_oauth(&self) -> bool {
self.config.auth.as_deref() == Some("oauth")
}
async fn chat_completions_inner(
&self,
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<ChatCompletionsOutput> {
let request_data = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data);
openai_chat_completions(builder, self.model()).await
}
async fn chat_completions_streaming_inner(
&self,
client: &ReqwestClient,
handler: &mut SseHandler,
data: ChatCompletionsData,
) -> Result<()> {
let request_data = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data);
openai_chat_completions_streaming(builder, handler, self.model()).await
}
async fn embeddings_inner(
&self,
client: &ReqwestClient,
data: &EmbeddingsData,
) -> Result<EmbeddingsOutput> {
let request_data = prepare_embeddings(self, client, data).await?;
let builder = self.request_builder(client, request_data);
openai_embeddings(builder, self.model()).await
}
async fn rerank_inner(
&self,
client: &ReqwestClient,
data: &RerankData,
) -> Result<RerankOutput> {
let request_data = prepare_rerank(self, client, data).await?;
let builder = self.request_builder(client, request_data);
generic_rerank(builder, self.model()).await
}
}
async fn prepare_chat_completions(
self_: &OpenAICompatibleClient, self_: &OpenAICompatibleClient,
client: &ReqwestClient,
data: ChatCompletionsData, data: ChatCompletionsData,
) -> Result<RequestData> { ) -> Result<RequestData> {
let api_key = self_.get_api_key().ok();
let api_base = get_api_base_ext(self_)?; let api_base = get_api_base_ext(self_)?;
let url = format!("{api_base}/chat/completions"); let url = format!("{api_base}/chat/completions");
let body = openai_build_chat_completions_body(data, &self_.model); let body = openai_build_chat_completions_body(data, &self_.model);
let mut request_data = RequestData::new(url, body); let mut request_data = RequestData::new(url, body);
if let Some(api_key) = api_key { apply_auth(self_, client, &mut request_data).await?;
request_data.bearer_auth(api_key);
}
Ok(request_data) Ok(request_data)
} }
fn prepare_embeddings( async fn prepare_embeddings(
self_: &OpenAICompatibleClient, self_: &OpenAICompatibleClient,
client: &ReqwestClient,
data: &EmbeddingsData, data: &EmbeddingsData,
) -> Result<RequestData> { ) -> Result<RequestData> {
let api_key = self_.get_api_key().ok();
let api_base = get_api_base_ext(self_)?; let api_base = get_api_base_ext(self_)?;
let url = format!("{api_base}/embeddings"); let url = format!("{api_base}/embeddings");
let body = openai_build_embeddings_body(data, &self_.model); let body = openai_build_embeddings_body(data, &self_.model);
let mut request_data = RequestData::new(url, body); let mut request_data = RequestData::new(url, body);
if let Some(api_key) = api_key { apply_auth(self_, client, &mut request_data).await?;
request_data.bearer_auth(api_key);
}
Ok(request_data) Ok(request_data)
} }
fn prepare_rerank(self_: &OpenAICompatibleClient, data: &RerankData) -> Result<RequestData> { async fn prepare_rerank(
let api_key = self_.get_api_key().ok(); self_: &OpenAICompatibleClient,
client: &ReqwestClient,
data: &RerankData,
) -> Result<RequestData> {
let api_base = get_api_base_ext(self_)?; let api_base = get_api_base_ext(self_)?;
let url = if self_.name().starts_with("ernie") { let url = if self_.name().starts_with("ernie") {
format!("{api_base}/rerankers") format!("{api_base}/rerankers")
} else { } else {
format!("{api_base}/rerank") format!("{api_base}/rerank")
}; };
let body = generic_build_rerank_body(data, &self_.model); let body = generic_build_rerank_body(data, &self_.model);
let mut request_data = RequestData::new(url, body); let mut request_data = RequestData::new(url, body);
apply_auth(self_, client, &mut request_data).await?;
Ok(request_data)
}
if let Some(api_key) = api_key { async fn apply_auth(
self_: &OpenAICompatibleClient,
client: &ReqwestClient,
request_data: &mut RequestData,
) -> Result<()> {
if self_.config.auth.as_deref() == Some("oauth") {
let client_name = self_.name();
let app_config = self_.app_config();
let cc = app_config
.clients
.iter()
.find(|cc| {
matches!(
cc,
ClientConfig::OpenAICompatibleConfig(c)
if c.name.as_deref().unwrap_or("openai-compatible") == client_name
)
})
.ok_or_else(|| {
anyhow!("Could not locate ClientConfig entry for '{}'", client_name)
})?;
let provider = oauth::get_oauth_provider_for_client(cc, &ALL_PROVIDER_MODELS)
.ok_or_else(|| {
anyhow!(
"OAuth configured for '{}' but no oauth block resolved (missing from both models.yaml and user config)",
client_name
)
})?;
let ready = oauth::prepare_oauth_access_token(client, &*provider, client_name).await?;
if !ready {
bail!(
"OAuth configured for '{}' but no tokens found. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
client_name,
client_name
);
}
let token = get_access_token(client_name)?;
request_data.bearer_auth(token);
for (key, value) in provider.extra_request_headers() {
request_data.header(key, value);
}
} else if let Ok(api_key) = self_.get_api_key() {
request_data.bearer_auth(api_key); request_data.bearer_auth(api_key);
} }
Ok(())
Ok(request_data)
} }
fn get_api_base_ext(self_: &OpenAICompatibleClient) -> Result<String> { fn get_api_base_ext(self_: &OpenAICompatibleClient) -> Result<String> {
+113
View File
@@ -0,0 +1,113 @@
use url::Url;
use super::oauth::{OAuthConfig, OAuthFlow, OAuthProvider, TokenRequestFormat};
pub struct OpenAICompatibleOAuthProvider {
pub config: OAuthConfig,
pub client_name: String,
}
fn is_loopback_uri(uri: &str) -> bool {
Url::parse(uri)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.is_some_and(|host| matches!(host.as_str(), "127.0.0.1" | "localhost" | "[::1]" | "::1"))
}
impl OAuthProvider for OpenAICompatibleOAuthProvider {
fn provider_name(&self) -> &str {
&self.client_name
}
fn client_id(&self) -> &str {
&self.config.client_id
}
fn authorize_url(&self) -> &str {
self.config.authorize_url.as_deref().unwrap_or("")
}
fn token_url(&self) -> &str {
&self.config.token_url
}
fn redirect_uri(&self) -> &str {
self.config.redirect_uri.as_deref().unwrap_or("")
}
fn scopes(&self) -> String {
self.config.scopes.join(" ")
}
fn client_secret(&self) -> Option<&str> {
self.config.client_secret.as_deref()
}
fn extra_authorize_params(&self) -> Vec<(&str, &str)> {
self.config
.extra_authorize_params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect()
}
fn token_request_format(&self) -> TokenRequestFormat {
self.config
.token_request_format
.unwrap_or(TokenRequestFormat::FormUrlEncoded)
}
fn uses_localhost_redirect(&self) -> bool {
self.config.redirect_uri.is_none() && self.config.redirect_port.is_none()
}
fn extra_token_headers(&self) -> Vec<(&str, &str)> {
self.config
.extra_token_headers
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect()
}
fn extra_request_headers(&self) -> Vec<(&str, &str)> {
self.config
.extra_request_headers
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect()
}
fn fixed_redirect_uri(&self) -> Option<String> {
if let Some(uri) = &self.config.redirect_uri {
return if is_loopback_uri(uri) {
Some(uri.clone())
} else {
None
};
}
if let Some(port) = self.config.redirect_port {
return Some(format!("http://127.0.0.1:{port}/callback"));
}
None
}
fn include_state_in_token_exchange(&self) -> bool {
self.config.include_state_in_token_exchange
}
fn flow(&self) -> OAuthFlow {
self.config.flow
}
fn echo_pkce_in_token_exchange(&self) -> bool {
self.config.echo_pkce_in_token_exchange
}
fn device_authorization_url(&self) -> Option<&str> {
self.config.device_authorization_url.as_deref()
}
fn use_pkce_in_device_flow(&self) -> bool {
self.config.use_pkce_in_device_flow
}
}
+2 -2
View File
@@ -26,8 +26,8 @@ impl OAuthProvider for OpenAIOAuthProvider {
"http://localhost:1455/auth/callback" "http://localhost:1455/auth/callback"
} }
fn scopes(&self) -> &str { fn scopes(&self) -> String {
"openid profile email offline_access" "openid profile email offline_access".to_string()
} }
fn token_request_format(&self) -> TokenRequestFormat { fn token_request_format(&self) -> TokenRequestFormat {
+13 -4
View File
@@ -1,4 +1,4 @@
use super::{ToolCall, catch_error}; use super::{ThinkingBlock, ToolCall, catch_error};
use crate::utils::AbortSignal; use crate::utils::AbortSignal;
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
@@ -13,6 +13,7 @@ pub struct SseHandler {
abort_signal: AbortSignal, abort_signal: AbortSignal,
buffer: String, buffer: String,
tool_calls: Vec<ToolCall>, tool_calls: Vec<ToolCall>,
thinking: Vec<ThinkingBlock>,
last_tool_calls: Vec<ToolCall>, last_tool_calls: Vec<ToolCall>,
max_call_repeats: usize, max_call_repeats: usize,
call_repeat_chain_len: usize, call_repeat_chain_len: usize,
@@ -26,6 +27,7 @@ impl SseHandler {
abort_signal, abort_signal,
buffer: String::new(), buffer: String::new(),
tool_calls: Vec::new(), tool_calls: Vec::new(),
thinking: Vec::new(),
last_tool_calls: Vec::new(), last_tool_calls: Vec::new(),
max_call_repeats: 2, max_call_repeats: 2,
call_repeat_chain_len: 3, call_repeat_chain_len: 3,
@@ -170,6 +172,10 @@ impl SseHandler {
message message
} }
pub fn thinking_block(&mut self, block: ThinkingBlock) {
self.thinking.push(block);
}
pub fn abort(&self) -> AbortSignal { pub fn abort(&self) -> AbortSignal {
self.abort_signal.clone() self.abort_signal.clone()
} }
@@ -179,11 +185,14 @@ impl SseHandler {
&self.last_tool_calls &self.last_tool_calls
} }
pub fn take(self) -> (String, Vec<ToolCall>) { pub fn take(self) -> (String, Vec<ToolCall>, Vec<ThinkingBlock>) {
let Self { let Self {
buffer, tool_calls, .. buffer,
tool_calls,
thinking,
..
} = self; } = self;
(buffer, tool_calls) (buffer, tool_calls, thinking)
} }
} }
+20 -5
View File
@@ -322,7 +322,11 @@ fn gemini_extract_chat_completions_text(data: &Value) -> Result<ChatCompletionsO
bail!("Invalid response data: {data}"); bail!("Invalid response data: {data}");
} }
} }
let output = ChatCompletionsOutput { text, tool_calls }; let output = ChatCompletionsOutput {
text,
tool_calls,
..Default::default()
};
Ok(output) Ok(output)
} }
@@ -334,6 +338,7 @@ pub fn gemini_build_chat_completions_body(
mut messages, mut messages,
temperature, temperature,
top_p, top_p,
reasoning_effort,
functions, functions,
stream: _, stream: _,
} = data; } = data;
@@ -371,8 +376,15 @@ pub fn gemini_build_chat_completions_body(
.collect(); .collect();
vec![json!({ "role": role, "parts": parts })] vec![json!({ "role": role, "parts": parts })]
}, },
MessageContent::ToolCalls(MessageContentToolCalls { tool_results, .. }) => { MessageContent::ToolCalls(MessageContentToolCalls { tool_results, text, .. }) => {
let model_parts: Vec<Value> = tool_results.iter().map(|tool_result| { let mut model_parts: Vec<Value> = vec![];
if !text.is_empty() {
model_parts.push(json!({ "text": text }));
}
for tool_result in tool_results.iter() {
if let Some(round_text) = &tool_result.text {
model_parts.push(json!({ "text": round_text }));
}
let mut part = json!({ let mut part = json!({
"functionCall": { "functionCall": {
"name": tool_result.call.name, "name": tool_result.call.name,
@@ -382,8 +394,8 @@ pub fn gemini_build_chat_completions_body(
if let Some(sig) = &tool_result.call.thought_signature { if let Some(sig) = &tool_result.call.thought_signature {
part["thoughtSignature"] = json!(sig); part["thoughtSignature"] = json!(sig);
} }
part model_parts.push(part);
}).collect(); }
let function_parts: Vec<Value> = tool_results.into_iter().map(|tool_result| { let function_parts: Vec<Value> = tool_results.into_iter().map(|tool_result| {
json!({ json!({
"functionResponse": { "functionResponse": {
@@ -426,6 +438,9 @@ pub fn gemini_build_chat_completions_body(
if let Some(v) = top_p { if let Some(v) = top_p {
body["generationConfig"]["topP"] = v.into(); body["generationConfig"]["topP"] = v.into();
} }
if let Some(v) = reasoning_effort {
body["generationConfig"]["thinking_config"] = json!({"thinking_level": v});
}
if let Some(functions) = functions { if let Some(functions) = functions {
// Gemini doesn't support functions with parameters that have empty properties, so we need to patch it. // Gemini doesn't support functions with parameters that have empty properties, so we need to patch it.
+112 -14
View File
@@ -43,6 +43,8 @@ pub struct Agent {
graph_rags: HashMap<String, Arc<Rag>>, graph_rags: HashMap<String, Arc<Rag>>,
model: Model, model: Model,
vault: GlobalVault, vault: GlobalVault,
is_graph: bool,
enabled_tools: Option<Vec<String>>,
} }
impl Agent { impl Agent {
@@ -219,7 +221,7 @@ impl Agent {
&& !matches!(agent_config.memory, Some(false)) && !matches!(agent_config.memory, Some(false))
&& !matches!(app.memory, Some(false)) && !matches!(app.memory, Some(false))
{ {
let memory_exists = paths::global_memory_index_path().exists() let memory_exists = paths::global_memory_index_file().exists()
|| env::current_dir() || env::current_dir()
.ok() .ok()
.and_then(|cwd| memory::discover_workspace_memory(&cwd)) .and_then(|cwd| memory::discover_workspace_memory(&cwd))
@@ -243,6 +245,8 @@ impl Agent {
graph_rags, graph_rags,
model, model,
vault: app_state.vault.clone(), vault: app_state.vault.clone(),
is_graph: graph_for_rag.is_some(),
enabled_tools: None,
}) })
} }
@@ -339,6 +343,10 @@ impl Agent {
&self.name &self.name
} }
pub fn is_graph(&self) -> bool {
self.is_graph
}
pub fn functions(&self) -> &Functions { pub fn functions(&self) -> &Functions {
&self.functions &self.functions
} }
@@ -359,6 +367,10 @@ impl Agent {
&self.config.mcp_servers &self.config.mcp_servers
} }
pub fn spawnable_agents(&self) -> Option<&[String]> {
self.config.spawnable_agents.as_deref()
}
pub fn skills_enabled(&self) -> Option<bool> { pub fn skills_enabled(&self) -> Option<bool> {
self.config.skills_enabled self.config.skills_enabled
} }
@@ -575,8 +587,12 @@ impl RoleLike for Agent {
self.config.top_p self.config.top_p
} }
fn reasoning_effort(&self) -> Option<String> {
self.config.reasoning_effort.clone()
}
fn enabled_tools(&self) -> Option<Vec<String>> { fn enabled_tools(&self) -> Option<Vec<String>> {
None self.enabled_tools.clone()
} }
fn enabled_mcp_servers(&self) -> Option<Vec<String>> { fn enabled_mcp_servers(&self) -> Option<Vec<String>> {
@@ -596,19 +612,18 @@ impl RoleLike for Agent {
self.config.top_p = value; self.config.top_p = value;
} }
fn set_reasoning_effort(&mut self, value: Option<String>) {
self.config.reasoning_effort = value;
}
fn set_enabled_tools(&mut self, value: Option<Vec<String>>) { fn set_enabled_tools(&mut self, value: Option<Vec<String>>) {
match value { self.enabled_tools = value.map(|tools| {
Some(tools) => { tools
self.config.global_tools = tools .into_iter()
.into_iter() .map(|v| v.trim().to_string())
.map(|v| v.trim().to_string()) .filter(|v| !v.is_empty())
.filter(|v| !v.is_empty()) .collect::<Vec<_>>()
.collect::<Vec<_>>(); });
}
None => {
self.config.global_tools.clear();
}
}
} }
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>) { fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>) {
@@ -637,11 +652,15 @@ pub struct AgentConfig {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>, pub top_p: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_session: Option<String>, pub agent_session: Option<String>,
#[serde(default)] #[serde(default)]
pub auto_continue: bool, pub auto_continue: bool,
#[serde(default)] #[serde(default)]
pub can_spawn_agents: bool, pub can_spawn_agents: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spawnable_agents: Option<Vec<String>>,
#[serde(default = "default_max_concurrent_agents")] #[serde(default = "default_max_concurrent_agents")]
pub max_concurrent_agents: usize, pub max_concurrent_agents: usize,
#[serde(default = "default_max_agent_depth")] #[serde(default = "default_max_agent_depth")]
@@ -732,6 +751,7 @@ impl AgentConfig {
model_id: graph.model.clone(), model_id: graph.model.clone(),
temperature: graph.temperature, temperature: graph.temperature,
top_p: graph.top_p, top_p: graph.top_p,
reasoning_effort: graph.reasoning_effort.clone(),
description: graph.description.clone(), description: graph.description.clone(),
global_tools: graph.global_tools.clone(), global_tools: graph.global_tools.clone(),
mcp_servers: graph.mcp_servers.clone(), mcp_servers: graph.mcp_servers.clone(),
@@ -766,6 +786,9 @@ impl AgentConfig {
if let Some(v) = read_env_value::<f64>(&with_prefix("top_p")) { if let Some(v) = read_env_value::<f64>(&with_prefix("top_p")) {
self.top_p = v; self.top_p = v;
} }
if let Some(v) = read_env_value::<String>(&with_prefix("reasoning_effort")) {
self.reasoning_effort = v;
}
if let Ok(v) = env::var(with_prefix("global_tools")) if let Ok(v) = env::var(with_prefix("global_tools"))
&& let Ok(v) = serde_json::from_str(&v) && let Ok(v) = serde_json::from_str(&v)
{ {
@@ -776,6 +799,11 @@ impl AgentConfig {
{ {
self.mcp_servers = v; self.mcp_servers = v;
} }
if let Ok(v) = env::var(with_prefix("spawnable_agents"))
&& let Ok(v) = serde_json::from_str(&v)
{
self.spawnable_agents = Some(v);
}
if let Some(v) = read_env_value::<String>(&with_prefix("agent_session")) { if let Some(v) = read_env_value::<String>(&with_prefix("agent_session")) {
self.agent_session = v; self.agent_session = v;
} }
@@ -999,6 +1027,36 @@ pub fn list_agents() -> Vec<String> {
agents agents
} }
pub fn list_agents_with_descriptions() -> Vec<(String, String)> {
list_agents()
.into_iter()
.map(|name| {
let description = load_agent_description(&name);
(name, description)
})
.collect()
}
#[derive(Deserialize)]
struct AgentMetadataStub {
#[serde(default)]
description: String,
}
fn load_agent_description(name: &str) -> String {
if let Ok(config) = AgentConfig::load(&paths::agent_config_file(name)) {
return config.description;
}
if let Ok(contents) = read_to_string(paths::agent_graph_file(name))
&& let Ok(meta) = serde_yaml::from_str::<AgentMetadataStub>(&contents)
{
return meta.description;
}
String::new()
}
pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option<String>)> { pub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option<String>)> {
let config_path = paths::agent_config_file(agent_name); let config_path = paths::agent_config_file(agent_name);
if !config_path.exists() { if !config_path.exists() {
@@ -1188,4 +1246,44 @@ variables:
assert_eq!(config.max_agent_depth, default_max_agent_depth()); assert_eq!(config.max_agent_depth, default_max_agent_depth());
assert_eq!(config.escalation_timeout, default_escalation_timeout()); assert_eq!(config.escalation_timeout, default_escalation_timeout());
} }
#[test]
fn agent_metadata_stub_extracts_description_from_graph_yaml() {
let yaml = r#"
name: librarian
description: External-reference research agent.
version: "1.0"
start: triage
nodes: {}
"#;
let meta: AgentMetadataStub = serde_yaml::from_str(yaml).unwrap();
assert_eq!(meta.description, "External-reference research agent.");
}
#[test]
fn agent_metadata_stub_extracts_multiline_description() {
let yaml = r#"
name: coder
description: |
Implementation agent. Plans, implements, and runs build + tests in a
bounded fix-loop until verified.
version: "1.0"
"#;
let meta: AgentMetadataStub = serde_yaml::from_str(yaml).unwrap();
assert!(meta.description.starts_with("Implementation agent."));
assert!(meta.description.contains("bounded fix-loop"));
}
#[test]
fn agent_metadata_stub_defaults_when_description_missing() {
let yaml = "name: nameless\nversion: \"1.0\"\n";
let meta: AgentMetadataStub = serde_yaml::from_str(yaml).unwrap();
assert_eq!(meta.description, "");
}
} }
+78 -14
View File
@@ -1,4 +1,4 @@
use crate::client::{ClientConfig, list_models}; use crate::client::{ClientConfig, Model, ModelType, list_models};
use crate::render::{MarkdownRender, RenderOptions}; use crate::render::{MarkdownRender, RenderOptions};
use crate::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, get_env_name}; use crate::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, get_env_name};
@@ -21,6 +21,7 @@ pub struct AppConfig {
pub model_id: String, pub model_id: String,
pub temperature: Option<f64>, pub temperature: Option<f64>,
pub top_p: Option<f64>, pub top_p: Option<f64>,
pub reasoning_effort: Option<String>,
pub dry_run: bool, pub dry_run: bool,
pub stream: bool, pub stream: bool,
@@ -68,6 +69,9 @@ pub struct AppConfig {
pub memory_cap_with_tools: Option<usize>, pub memory_cap_with_tools: Option<usize>,
pub memory_cap_without_tools: Option<usize>, pub memory_cap_without_tools: Option<usize>,
pub workspace_instructions: Option<bool>,
pub workspace_instructions_files: Option<Vec<String>>,
pub rag_embedding_model: Option<String>, pub rag_embedding_model: Option<String>,
pub rag_reranker_model: Option<String>, pub rag_reranker_model: Option<String>,
pub rag_top_k: usize, pub rag_top_k: usize,
@@ -82,12 +86,14 @@ pub struct AppConfig {
pub document_loaders: HashMap<String, String>, pub document_loaders: HashMap<String, String>,
pub highlight: bool, pub highlight: bool,
pub raw_markdown: bool,
pub theme: Option<String>, pub theme: Option<String>,
pub left_prompt: Option<String>, pub left_prompt: Option<String>,
pub right_prompt: Option<String>, pub right_prompt: Option<String>,
pub user_agent: Option<String>, pub user_agent: Option<String>,
pub save_shell_history: bool, pub save_shell_history: bool,
pub no_workspace_mcp: bool,
pub sync_models_url: Option<String>, pub sync_models_url: Option<String>,
pub clients: Vec<ClientConfig>, pub clients: Vec<ClientConfig>,
@@ -99,6 +105,7 @@ impl Default for AppConfig {
model_id: Default::default(), model_id: Default::default(),
temperature: None, temperature: None,
top_p: None, top_p: None,
reasoning_effort: None,
dry_run: false, dry_run: false,
stream: true, stream: true,
@@ -143,6 +150,9 @@ impl Default for AppConfig {
memory_cap_with_tools: None, memory_cap_with_tools: None,
memory_cap_without_tools: None, memory_cap_without_tools: None,
workspace_instructions: None,
workspace_instructions_files: None,
rag_embedding_model: None, rag_embedding_model: None,
rag_reranker_model: None, rag_reranker_model: None,
rag_top_k: 5, rag_top_k: 5,
@@ -156,12 +166,14 @@ impl Default for AppConfig {
document_loaders: Default::default(), document_loaders: Default::default(),
highlight: true, highlight: true,
raw_markdown: false,
theme: None, theme: None,
left_prompt: None, left_prompt: None,
right_prompt: None, right_prompt: None,
user_agent: None, user_agent: None,
save_shell_history: true, save_shell_history: true,
no_workspace_mcp: false,
sync_models_url: None, sync_models_url: None,
clients: vec![], clients: vec![],
@@ -175,6 +187,7 @@ impl AppConfig {
model_id: config.model_id, model_id: config.model_id,
temperature: config.temperature, temperature: config.temperature,
top_p: config.top_p, top_p: config.top_p,
reasoning_effort: None,
dry_run: config.dry_run, dry_run: config.dry_run,
stream: config.stream, stream: config.stream,
@@ -219,6 +232,9 @@ impl AppConfig {
memory_cap_with_tools: config.memory_cap_with_tools, memory_cap_with_tools: config.memory_cap_with_tools,
memory_cap_without_tools: config.memory_cap_without_tools, memory_cap_without_tools: config.memory_cap_without_tools,
workspace_instructions: config.workspace_instructions,
workspace_instructions_files: config.workspace_instructions_files,
rag_embedding_model: config.rag_embedding_model, rag_embedding_model: config.rag_embedding_model,
rag_reranker_model: config.rag_reranker_model, rag_reranker_model: config.rag_reranker_model,
rag_top_k: config.rag_top_k, rag_top_k: config.rag_top_k,
@@ -232,12 +248,14 @@ impl AppConfig {
document_loaders: config.document_loaders, document_loaders: config.document_loaders,
highlight: config.highlight, highlight: config.highlight,
raw_markdown: config.raw_markdown,
theme: config.theme, theme: config.theme,
left_prompt: config.left_prompt, left_prompt: config.left_prompt,
right_prompt: config.right_prompt, right_prompt: config.right_prompt,
user_agent: config.user_agent, user_agent: config.user_agent,
save_shell_history: config.save_shell_history, save_shell_history: config.save_shell_history,
no_workspace_mcp: false,
sync_models_url: config.sync_models_url, sync_models_url: config.sync_models_url,
clients: config.clients, clients: config.clients,
@@ -250,6 +268,7 @@ impl AppConfig {
app_config.setup_document_loaders(); app_config.setup_document_loaders();
app_config.setup_user_agent(); app_config.setup_user_agent();
app_config.resolve_model()?; app_config.resolve_model()?;
app_config.validate_reasoning_effort()?;
Ok(app_config) Ok(app_config)
} }
@@ -270,6 +289,31 @@ impl AppConfig {
Ok(()) Ok(())
} }
fn validate_reasoning_effort(&self) -> Result<()> {
let Some(ref effort) = self.reasoning_effort else {
return Ok(());
};
let model = Model::retrieve_model(self, &self.model_id, ModelType::Chat)?;
let levels = model.reasoning_levels();
if levels.is_empty() {
bail!(
"reasoning_effort '{}' is configured but the model does not support reasoning effort",
effort
);
}
if !levels.iter().any(|l| l == effort) {
bail!(
"reasoning_effort '{}' is not valid for the model. Supported levels: {}",
effort,
levels.join(", ")
);
}
Ok(())
}
pub fn resolve_model(&mut self) -> Result<()> { pub fn resolve_model(&mut self) -> Result<()> {
if self.model_id.is_empty() { if self.model_id.is_empty() {
let models = list_models(self, crate::client::ModelType::Chat); let models = list_models(self, crate::client::ModelType::Chat);
@@ -288,7 +332,7 @@ impl AppConfig {
return path.clone(); return path.clone();
} }
if let Some(translated) = paths::translate_sandboxed_home_path(path) if let Some(translated) = paths::translate_sandboxed_home_dir(path)
&& translated.exists() && translated.exists()
{ {
info!( info!(
@@ -308,16 +352,18 @@ impl AppConfig {
pub fn editor(&self) -> Result<String> { pub fn editor(&self) -> Result<String> {
super::EDITOR.get_or_init(move || { super::EDITOR.get_or_init(move || {
let editor = self.editor.clone() if let Some(editor) = self.editor.clone()
.or_else(|| env::var("VISUAL").ok().or_else(|| env::var("EDITOR").ok())) .or_else(|| env::var("VISUAL").ok().or_else(|| env::var("EDITOR").ok()))
.unwrap_or_else(|| { && which::which(&editor).is_ok()
if cfg!(windows) { {
"notepad".to_string() return Some(editor);
} else { }
"nano".to_string() let default = if cfg!(windows) {
} "notepad".to_string()
}); } else {
which::which(&editor).ok().map(|_| editor) "nano".to_string()
};
which::which(&default).ok().map(|_| default)
}) })
.clone() .clone()
.ok_or_else(|| anyhow!("Editor not found. Please add the `editor` configuration or set the $EDITOR or $VISUAL environment variable.")) .ok_or_else(|| anyhow!("Editor not found. Please add the `editor` configuration or set the $EDITOR or $VISUAL environment variable."))
@@ -337,7 +383,7 @@ impl AppConfig {
let theme = if self.highlight { let theme = if self.highlight {
let theme_mode = if self.light_theme() { "light" } else { "dark" }; let theme_mode = if self.light_theme() { "light" } else { "dark" };
let theme_filename = format!("{theme_mode}.tmTheme"); let theme_filename = format!("{theme_mode}.tmTheme");
let theme_path = paths::local_path(&theme_filename); let theme_path = paths::local_dir(&theme_filename);
if theme_path.exists() { if theme_path.exists() {
let theme = ThemeSet::get_theme(&theme_path) let theme = ThemeSet::get_theme(&theme_path)
.with_context(|| format!("Invalid theme at '{}'", theme_path.display()))?; .with_context(|| format!("Invalid theme at '{}'", theme_path.display()))?;
@@ -362,14 +408,26 @@ impl AppConfig {
env::var("COLORTERM").as_ref().map(|v| v.as_str()), env::var("COLORTERM").as_ref().map(|v| v.as_str()),
Ok("truecolor") Ok("truecolor")
); );
Ok(RenderOptions::new(theme, wrap, self.wrap_code, truecolor)) Ok(RenderOptions::new(
theme,
wrap,
self.wrap_code,
self.raw_markdown,
truecolor,
))
} }
pub fn print_markdown(&self, text: &str) -> Result<()> { pub fn print_markdown(&self, text: &str) -> Result<()> {
if *IS_STDOUT_TERMINAL { if *IS_STDOUT_TERMINAL {
let render_options = self.render_options()?; let render_options = self.render_options()?;
let mut markdown_render = MarkdownRender::init(render_options)?; let mut markdown_render = MarkdownRender::init(render_options)?;
println!("{}", markdown_render.render(text)); let body = markdown_render.render(text);
let tail = markdown_render.finalize();
if tail.is_empty() {
println!("{body}");
} else {
println!("{body}\n{tail}");
}
} else { } else {
println!("{text}"); println!("{text}");
} }
@@ -421,6 +479,9 @@ impl AppConfig {
if let Some(v) = super::read_env_value::<f64>(&get_env_name("top_p")) { if let Some(v) = super::read_env_value::<f64>(&get_env_name("top_p")) {
self.top_p = v; self.top_p = v;
} }
if let Some(v) = super::read_env_value::<String>(&get_env_name("reasoning_effort")) {
self.reasoning_effort = v;
}
if let Some(Some(v)) = super::read_env_bool(&get_env_name("dry_run")) { if let Some(Some(v)) = super::read_env_bool(&get_env_name("dry_run")) {
self.dry_run = v; self.dry_run = v;
@@ -543,6 +604,9 @@ impl AppConfig {
if *NO_COLOR { if *NO_COLOR {
self.highlight = false; self.highlight = false;
} }
if let Some(Some(v)) = super::read_env_bool(&get_env_name("raw_markdown")) {
self.raw_markdown = v;
}
if self.highlight && self.theme.is_none() { if self.highlight && self.theme.is_none() {
if let Some(v) = super::read_env_value::<String>(&get_env_name("theme")) { if let Some(v) = super::read_env_value::<String>(&get_env_name("theme")) {
self.theme = v; self.theme = v;
+5
View File
@@ -253,6 +253,10 @@ impl Input {
patch_messages(&mut messages, model); patch_messages(&mut messages, model);
model.guard_max_input_tokens(&messages)?; model.guard_max_input_tokens(&messages)?;
let (temperature, top_p) = (self.role().temperature(), self.role().top_p()); let (temperature, top_p) = (self.role().temperature(), self.role().top_p());
let reasoning_effort = self
.role()
.reasoning_effort()
.or_else(|| model.default_reasoning_effort().map(|s| s.to_string()));
let functions = if model.supports_function_calling() { let functions = if model.supports_function_calling() {
let fns = self.functions.clone(); let fns = self.functions.clone();
if let Some(vec) = &fns { if let Some(vec) = &fns {
@@ -268,6 +272,7 @@ impl Input {
messages, messages,
temperature, temperature,
top_p, top_p,
reasoning_effort,
functions, functions,
stream, stream,
}) })
+211
View File
@@ -0,0 +1,211 @@
use std::fs;
use std::path::{Path, PathBuf};
use log::warn;
pub const WORKSPACE_INSTRUCTIONS_FILE_NAME: &str = "COYOTE.md";
pub const DEFAULT_WORKSPACE_INSTRUCTIONS_FILES: [&str; 4] = [
WORKSPACE_INSTRUCTIONS_FILE_NAME,
"AGENTS.md",
"CLAUDE.md",
"GEMINI.md",
];
const INSTRUCTIONS_SIZE_WARN_THRESHOLD: usize = 24_000;
#[derive(Debug, Clone)]
pub struct WorkspaceInstructions {
pub path: PathBuf,
pub content: String,
}
pub fn default_workspace_instructions_files() -> Vec<String> {
DEFAULT_WORKSPACE_INSTRUCTIONS_FILES
.iter()
.map(|s| s.to_string())
.collect()
}
pub fn discover_workspace_instructions(
start: &Path,
file_names: &[String],
) -> Option<WorkspaceInstructions> {
for dir in start.ancestors() {
for name in file_names {
let candidate = dir.join(name);
if !candidate.is_file() {
continue;
}
match fs::read_to_string(&candidate) {
Ok(content) if !content.trim().is_empty() => {
return Some(WorkspaceInstructions {
path: candidate,
content,
});
}
Ok(_) => {}
Err(e) => warn!(
"failed to read workspace instructions at {}: {e}",
candidate.display()
),
}
}
}
None
}
pub fn build_instructions_section(instructions: &WorkspaceInstructions) -> String {
let char_count = instructions.content.chars().count();
if char_count > INSTRUCTIONS_SIZE_WARN_THRESHOLD {
warn!(
"workspace instructions at {} are large ({char_count} chars); \
consider moving detail into workspace memory drill files",
instructions.path.display()
);
}
format!(
"<workspace_instructions source=\"{}\">\n{}\n</workspace_instructions>",
instructions.path.display(),
instructions.content.trim_end()
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{env, time};
use time::SystemTime;
fn temp_root(label: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = env::temp_dir().join(format!("coyote-instructions-{label}-{unique}"));
fs::create_dir_all(&root).unwrap();
root
}
fn defaults() -> Vec<String> {
default_workspace_instructions_files()
}
#[test]
fn discovery_returns_none_when_no_file_exists() {
let root = temp_root("none");
assert!(discover_workspace_instructions(&root, &defaults()).is_none());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_finds_coyote_md() {
let root = temp_root("coyote");
fs::write(root.join("COYOTE.md"), "coyote instructions").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("COYOTE.md"));
assert_eq!(found.content, "coyote instructions");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_falls_back_through_chain_in_order() {
let root = temp_root("fallback");
fs::write(root.join("GEMINI.md"), "gemini instructions").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("GEMINI.md"));
fs::write(root.join("CLAUDE.md"), "claude instructions").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("CLAUDE.md"));
fs::write(root.join("AGENTS.md"), "agents instructions").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("AGENTS.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_prefers_coyote_md_over_fallbacks() {
let root = temp_root("precedence");
fs::write(root.join("COYOTE.md"), "coyote").unwrap();
fs::write(root.join("AGENTS.md"), "agents").unwrap();
fs::write(root.join("CLAUDE.md"), "claude").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("COYOTE.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_walks_up_from_nested_dir() {
let root = temp_root("walk_up");
fs::write(root.join("AGENTS.md"), "root instructions").unwrap();
let nested = root.join("src").join("deep");
fs::create_dir_all(&nested).unwrap();
let found = discover_workspace_instructions(&nested, &defaults()).unwrap();
assert_eq!(found.path, root.join("AGENTS.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_prefers_closer_file_over_higher_priority_name_above() {
let root = temp_root("depth_first");
fs::write(root.join("COYOTE.md"), "root coyote").unwrap();
let nested = root.join("packages").join("app");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("CLAUDE.md"), "nested claude").unwrap();
let found = discover_workspace_instructions(&nested, &defaults()).unwrap();
assert_eq!(found.path, nested.join("CLAUDE.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_skips_empty_files() {
let root = temp_root("empty");
fs::write(root.join("COYOTE.md"), " \n").unwrap();
fs::write(root.join("AGENTS.md"), "real content").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("AGENTS.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_honors_custom_file_chain() {
let root = temp_root("custom");
fs::write(root.join("CLAUDE.md"), "claude").unwrap();
let only_agents = vec!["AGENTS.md".to_string()];
assert!(discover_workspace_instructions(&root, &only_agents).is_none());
let empty: Vec<String> = vec![];
assert!(discover_workspace_instructions(&root, &empty).is_none());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn build_section_wraps_content_with_source_path() {
let instructions = WorkspaceInstructions {
path: PathBuf::from("/ws/COYOTE.md"),
content: "Do the thing.\n".into(),
};
let section = build_instructions_section(&instructions);
assert!(section.starts_with("<workspace_instructions source=\"/ws/COYOTE.md\">"));
assert!(section.contains("Do the thing."));
assert!(section.ends_with("</workspace_instructions>"));
}
}
+3 -3
View File
@@ -3,7 +3,7 @@ use crate::mcp::{
spawn_mcp_server, spawn_mcp_server,
}; };
use anyhow::{Result, anyhow}; use anyhow::Result;
use parking_lot::Mutex; use parking_lot::Mutex;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
@@ -111,10 +111,10 @@ impl McpFactory {
.await .await
.map_err(|e| { .map_err(|e| {
if is_auth_required_error(&e) { if is_auth_required_error(&e) {
anyhow!( e.context(format!(
"MCP server '{name}' requires OAuth authentication. \ "MCP server '{name}' requires OAuth authentication. \
Run `coyote --auth-mcp {name}` or `.mcp auth {name}` in the REPL to authenticate." Run `coyote --auth-mcp {name}` or `.mcp auth {name}` in the REPL to authenticate."
) ))
} else { } else {
e e
} }
+25 -45
View File
@@ -7,41 +7,27 @@ use serde::{Deserialize, Serialize};
use crate::config::{ use crate::config::{
GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME,
WORKSPACE_MEMORY_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME, paths, WORKSPACE_COYOTE_DIR_NAME, paths,
}; };
pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000; pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000;
pub const DEFAULT_MEMORY_CAP_WITHOUT_TOOLS: usize = 12_000; pub const DEFAULT_MEMORY_CAP_WITHOUT_TOOLS: usize = 12_000;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum WorkspaceMemory { pub struct WorkspaceMemory {
Structured { pub workspace_root: PathBuf,
workspace_root: PathBuf, pub dir: PathBuf,
dir: PathBuf,
},
Lite {
workspace_root: PathBuf,
file: PathBuf,
},
} }
pub fn discover_workspace_memory(start: &Path) -> Option<WorkspaceMemory> { pub fn discover_workspace_memory(start: &Path) -> Option<WorkspaceMemory> {
for dir in start.ancestors() { for dir in start.ancestors() {
let structured = dir.join(WORKSPACE_MEMORY_DIR_NAME).join(MEMORY_DIR_NAME); let structured = dir.join(WORKSPACE_COYOTE_DIR_NAME).join(MEMORY_DIR_NAME);
if structured.join(MEMORY_INDEX_FILE_NAME).exists() { if structured.join(MEMORY_INDEX_FILE_NAME).exists() {
return Some(WorkspaceMemory::Structured { return Some(WorkspaceMemory {
workspace_root: dir.to_path_buf(), workspace_root: dir.to_path_buf(),
dir: structured, dir: structured,
}); });
} }
let lite = dir.join(WORKSPACE_MEMORY_FILE_NAME);
if lite.exists() {
return Some(WorkspaceMemory::Lite {
workspace_root: dir.to_path_buf(),
file: lite,
});
}
} }
None None
} }
@@ -82,10 +68,10 @@ pub fn bootstrap_workspace_memory(git_root: &Path) -> Result<PathBuf> {
Ok(mem_dir) Ok(mem_dir)
} }
fn append_gitignore_entry(git_root: &Path) -> Result<bool> { pub fn append_gitignore_entry(git_root: &Path) -> Result<bool> {
let gitignore = git_root.join(GITIGNORE_FILE_NAME); let gitignore = git_root.join(GITIGNORE_FILE_NAME);
let entry = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}/"); let entry = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}/");
let entry_no_slash = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}"); let entry_no_slash = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}");
let existing = fs::read_to_string(&gitignore).unwrap_or_default(); let existing = fs::read_to_string(&gitignore).unwrap_or_default();
let already_present = existing.lines().any(|line| { let already_present = existing.lines().any(|line| {
@@ -212,9 +198,8 @@ impl MemoryStore {
pub fn load_workspace_index(&self) -> Result<Option<String>> { pub fn load_workspace_index(&self) -> Result<Option<String>> {
match &self.workspace { match &self.workspace {
None => Ok(None), None => Ok(None),
Some(WorkspaceMemory::Lite { file, .. }) => Ok(Some(fs::read_to_string(file)?)), Some(ws) => {
Some(WorkspaceMemory::Structured { dir, .. }) => { let index = ws.dir.join(MEMORY_INDEX_FILE_NAME);
let index = dir.join(MEMORY_INDEX_FILE_NAME);
if index.exists() { if index.exists() {
Ok(Some(fs::read_to_string(index)?)) Ok(Some(fs::read_to_string(index)?))
} else { } else {
@@ -231,8 +216,8 @@ impl MemoryStore {
collect_md_files(&self.global_dir, &mut out)?; collect_md_files(&self.global_dir, &mut out)?;
} }
if let Some(WorkspaceMemory::Structured { dir, .. }) = &self.workspace { if let Some(ws) = &self.workspace {
collect_md_files(dir, &mut out)?; collect_md_files(&ws.dir, &mut out)?;
} }
Ok(out) Ok(out)
@@ -347,7 +332,7 @@ mod tests {
let root = temp_root("phase1"); let root = temp_root("phase1");
let workspace = root.join("workspace"); let workspace = root.join("workspace");
let workspace_memory_dir = workspace let workspace_memory_dir = workspace
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME); .join(MEMORY_DIR_NAME);
fs::create_dir_all(&workspace_memory_dir).unwrap(); fs::create_dir_all(&workspace_memory_dir).unwrap();
fs::write( fs::write(
@@ -378,18 +363,13 @@ mod tests {
} }
#[test] #[test]
fn workspace_discovery_prefers_structured_over_lite() { fn workspace_discovery_ignores_root_instructions_file() {
let root = temp_root("prefer"); let root = temp_root("no_lite");
let workspace = root.join("ws"); let workspace = root.join("ws");
let structured = workspace fs::create_dir_all(&workspace).unwrap();
.join(WORKSPACE_MEMORY_DIR_NAME) fs::write(workspace.join("COYOTE.md"), "instructions, not memory").unwrap();
.join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap();
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "s").unwrap();
fs::write(workspace.join(WORKSPACE_MEMORY_FILE_NAME), "l").unwrap();
let found = discover_workspace_memory(&workspace); assert!(discover_workspace_memory(&workspace).is_none());
assert!(matches!(found, Some(WorkspaceMemory::Structured { .. })));
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
} }
@@ -415,7 +395,7 @@ mod tests {
let root = temp_root("indexes_only"); let root = temp_root("indexes_only");
let workspace = root.join("ws"); let workspace = root.join("ws");
let structured = workspace let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME); .join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap(); fs::create_dir_all(&structured).unwrap();
fs::write( fs::write(
@@ -450,7 +430,7 @@ mod tests {
let root = temp_root("drill_bodies"); let root = temp_root("drill_bodies");
let workspace = root.join("ws"); let workspace = root.join("ws");
let structured = workspace let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME); .join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap(); fs::create_dir_all(&structured).unwrap();
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap(); fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
@@ -485,7 +465,7 @@ mod tests {
let root = temp_root("cap"); let root = temp_root("cap");
let workspace = root.join("ws"); let workspace = root.join("ws");
let structured = workspace let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME); .join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap(); fs::create_dir_all(&structured).unwrap();
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap(); fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
@@ -575,15 +555,15 @@ mod tests {
let root = temp_root("walk_up"); let root = temp_root("walk_up");
let workspace = root.join("ws"); let workspace = root.join("ws");
let mem_dir = workspace let mem_dir = workspace
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME); .join(MEMORY_DIR_NAME);
fs::create_dir_all(&mem_dir).unwrap(); fs::create_dir_all(&mem_dir).unwrap();
fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap(); fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
let nested = workspace.join("src").join("deep").join("path"); let nested = workspace.join("src").join("deep").join("path");
fs::create_dir_all(&nested).unwrap(); fs::create_dir_all(&nested).unwrap();
let found = discover_workspace_memory(&nested); let found = discover_workspace_memory(&nested).expect("workspace memory should be found");
assert!(matches!(found, Some(WorkspaceMemory::Structured { .. }))); assert_eq!(found.dir, mem_dir);
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
} }
+29 -7
View File
@@ -3,6 +3,7 @@ mod app_config;
mod app_state; mod app_state;
mod input; mod input;
mod install_remote; mod install_remote;
pub(crate) mod instructions;
mod macros; mod macros;
mod mcp_factory; mod mcp_factory;
pub(crate) mod memory; pub(crate) mod memory;
@@ -21,6 +22,7 @@ mod update;
pub use self::agent::{ pub use self::agent::{
Agent, AgentVariable, AgentVariables, complete_agent_variables, list_agents, Agent, AgentVariable, AgentVariables, complete_agent_variables, list_agents,
list_agents_with_descriptions,
}; };
#[allow(unused_imports)] #[allow(unused_imports)]
pub use self::app_config::AppConfig; pub use self::app_config::AppConfig;
@@ -33,7 +35,7 @@ pub use self::request_context::{RenderMode, RequestContext, should_inject_skill_
pub use self::role::{ pub use self::role::{
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE, CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
}; };
use self::session::Session; pub use self::session::Session;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use self::skill::Skill; pub use self::skill::Skill;
#[allow(unused_imports)] #[allow(unused_imports)]
@@ -43,7 +45,7 @@ pub use self::skill_registry::SkillRegistry;
pub use self::update::run_self_update; pub use self::update::run_self_update;
use crate::client::{ use crate::client::{
ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS, ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
ProviderModels, create_client_config, list_client_types, ProviderModels, create_client_config, list_client_types, oauth,
}; };
use crate::function::{FunctionDeclaration, Functions}; use crate::function::{FunctionDeclaration, Functions};
use crate::rag::Rag; use crate::rag::Rag;
@@ -62,7 +64,7 @@ use indoc::formatdoc;
use inquire::{Confirm, Select}; use inquire::{Confirm, Select};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::sync::LazyLock; use std::sync::LazyLock;
use std::{ use std::{
env, env,
@@ -140,10 +142,10 @@ const GLOBAL_TOOLS_DIR_NAME: &str = "tools";
const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils"; const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils";
const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh"; const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh";
const MCP_FILE_NAME: &str = "mcp.json"; const MCP_FILE_NAME: &str = "mcp.json";
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
const MEMORY_DIR_NAME: &str = "memory"; const MEMORY_DIR_NAME: &str = "memory";
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md"; const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md"; const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote";
const SBX_KIT_DIR_NAME: &str = "sbx-kit"; const SBX_KIT_DIR_NAME: &str = "sbx-kit";
const SBX_KIT_HASH_FILE: &str = "kit.sha256"; const SBX_KIT_HASH_FILE: &str = "kit.sha256";
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml"; const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
@@ -183,7 +185,7 @@ const SUMMARIZATION_PROMPT: &str =
const SUMMARY_CONTEXT_PROMPT: &str = "This is a summary of the chat history as a recap: "; const SUMMARY_CONTEXT_PROMPT: &str = "This is a summary of the chat history as a recap: ";
const LEFT_PROMPT: &str = "{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} "; const LEFT_PROMPT: &str = "{color.red}{model}){color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} ";
const RIGHT_PROMPT: &str = "{color.purple}{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}"; const RIGHT_PROMPT: &str = "{color.cyan}{?reasoning_effort [{reasoning_effort}] }{color.purple}{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}";
static EDITOR: OnceLock<Option<String>> = OnceLock::new(); static EDITOR: OnceLock<Option<String>> = OnceLock::new();
@@ -244,6 +246,9 @@ pub struct Config {
pub memory_cap_with_tools: Option<usize>, pub memory_cap_with_tools: Option<usize>,
pub memory_cap_without_tools: Option<usize>, pub memory_cap_without_tools: Option<usize>,
pub workspace_instructions: Option<bool>,
pub workspace_instructions_files: Option<Vec<String>>,
pub rag_embedding_model: Option<String>, pub rag_embedding_model: Option<String>,
pub rag_reranker_model: Option<String>, pub rag_reranker_model: Option<String>,
pub rag_top_k: usize, pub rag_top_k: usize,
@@ -258,6 +263,7 @@ pub struct Config {
pub document_loaders: HashMap<String, String>, pub document_loaders: HashMap<String, String>,
pub highlight: bool, pub highlight: bool,
pub raw_markdown: bool,
pub theme: Option<String>, pub theme: Option<String>,
pub left_prompt: Option<String>, pub left_prompt: Option<String>,
pub right_prompt: Option<String>, pub right_prompt: Option<String>,
@@ -319,6 +325,9 @@ impl Default for Config {
memory_cap_with_tools: None, memory_cap_with_tools: None,
memory_cap_without_tools: None, memory_cap_without_tools: None,
workspace_instructions: None,
workspace_instructions_files: None,
rag_embedding_model: None, rag_embedding_model: None,
rag_reranker_model: None, rag_reranker_model: None,
rag_top_k: 5, rag_top_k: 5,
@@ -332,6 +341,7 @@ impl Default for Config {
document_loaders: Default::default(), document_loaders: Default::default(),
highlight: true, highlight: true,
raw_markdown: false,
theme: None, theme: None,
left_prompt: None, left_prompt: None,
right_prompt: None, right_prompt: None,
@@ -474,7 +484,7 @@ fn confirm_asset_overwrite(category: AssetCategory, label: &str, target: &Path)
pub fn default_sessions_dir() -> PathBuf { pub fn default_sessions_dir() -> PathBuf {
match env::var(get_env_name("sessions_dir")) { match env::var(get_env_name("sessions_dir")) {
Ok(value) => PathBuf::from(value), Ok(value) => PathBuf::from(value),
Err(_) => paths::local_path(SESSIONS_DIR_NAME), Err(_) => paths::local_dir(SESSIONS_DIR_NAME),
} }
} }
@@ -589,6 +599,18 @@ impl Config {
}) })
.with_context(|| "Failed to load config from str")?; .with_context(|| "Failed to load config from str")?;
let mut seen = HashSet::new();
for cc in &config.clients {
let (name, _, _) = oauth::client_config_info(cc);
if !seen.insert(name.to_string()) {
bail!(
"Duplicate client name '{name}' in config.yaml. \
Client names must be unique across all `clients[]` entries \
to avoid OAuth token collisions."
);
}
}
Ok(config) Ok(config)
} }
+200 -58
View File
@@ -2,10 +2,10 @@ use super::role::Role;
use super::{ use super::{
AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME, AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME,
ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME, ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME,
GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, MACROS_DIR_NAME, MCP_FILE_NAME,
MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME,
SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, SBX_VAULT_MIXINS_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME,
SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME, SBX_VAULT_MIXINS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
}; };
use crate::client::ProviderModels; use crate::client::ProviderModels;
use crate::config::REPL_HISTORY_DIR_NAME; use crate::config::REPL_HISTORY_DIR_NAME;
@@ -30,11 +30,11 @@ pub fn config_dir() -> PathBuf {
} }
} }
pub fn local_path(name: &str) -> PathBuf { pub fn local_dir(name: &str) -> PathBuf {
config_dir().join(name) config_dir().join(name)
} }
pub fn cache_path() -> PathBuf { pub fn cache_dir() -> PathBuf {
if let Ok(v) = env::var(get_env_name("cache_dir")) { if let Ok(v) = env::var(get_env_name("cache_dir")) {
PathBuf::from(v) PathBuf::from(v)
} else if let Ok(v) = env::var("XDG_CACHE_HOME") { } else if let Ok(v) = env::var("XDG_CACHE_HOME") {
@@ -49,7 +49,7 @@ pub fn sandbox_kit_override() -> Option<PathBuf> {
env::var_os(get_env_name("sandbox_kit")).map(PathBuf::from) env::var_os(get_env_name("sandbox_kit")).map(PathBuf::from)
} }
pub fn translate_sandboxed_home_path(path: &Path) -> Option<PathBuf> { pub fn translate_sandboxed_home_dir(path: &Path) -> Option<PathBuf> {
env::var_os("IS_SANDBOX")?; env::var_os("IS_SANDBOX")?;
let s = path.to_str()?; let s = path.to_str()?;
@@ -62,7 +62,7 @@ pub fn translate_sandboxed_home_path(path: &Path) -> Option<PathBuf> {
return Some(translated); return Some(translated);
} }
translate_windows_users_path(s) translate_windows_users_dir(s)
} }
fn translate_unix_home_style(s: &str, prefix: &str) -> Option<PathBuf> { fn translate_unix_home_style(s: &str, prefix: &str) -> Option<PathBuf> {
@@ -83,7 +83,7 @@ fn translate_unix_home_style(s: &str, prefix: &str) -> Option<PathBuf> {
}) })
} }
fn translate_windows_users_path(s: &str) -> Option<PathBuf> { fn translate_windows_users_dir(s: &str) -> Option<PathBuf> {
let bytes = s.as_bytes(); let bytes = s.as_bytes();
if bytes.len() < 4 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' || bytes[2] != b'\\' { if bytes.len() < 4 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' || bytes[2] != b'\\' {
return None; return None;
@@ -118,7 +118,7 @@ pub fn global_tools_sbx_mixin_file() -> PathBuf {
pub fn find_workspace_sbx_mixin(start: &Path) -> Option<PathBuf> { pub fn find_workspace_sbx_mixin(start: &Path) -> Option<PathBuf> {
for dir in start.ancestors() { for dir in start.ancestors() {
let candidate = dir let candidate = dir
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(SBX_MIXIN_FILE_NAME); .join(SBX_MIXIN_FILE_NAME);
if candidate.exists() { if candidate.exists() {
return Some(candidate); return Some(candidate);
@@ -128,20 +128,20 @@ pub fn find_workspace_sbx_mixin(start: &Path) -> Option<PathBuf> {
None None
} }
pub fn oauth_tokens_path() -> PathBuf { pub fn oauth_tokens_dir() -> PathBuf {
cache_path().join("oauth") cache_dir().join("oauth")
} }
pub fn token_file(client_name: &str) -> PathBuf { pub fn token_file(client_name: &str) -> PathBuf {
oauth_tokens_path().join(format!("{client_name}_oauth_tokens.json")) oauth_tokens_dir().join(format!("{client_name}_oauth_tokens.json"))
} }
pub fn log_path() -> PathBuf { pub fn log_file() -> PathBuf {
cache_path().join(format!("{}.log", env!("CARGO_CRATE_NAME"))) cache_dir().join(format!("{}.log", env!("CARGO_CRATE_NAME")))
} }
pub fn sbx_kit_dir() -> PathBuf { pub fn sbx_kit_dir() -> PathBuf {
cache_path().join(SBX_KIT_DIR_NAME) cache_dir().join(SBX_KIT_DIR_NAME)
} }
pub fn sbx_kit_hash_file() -> PathBuf { pub fn sbx_kit_hash_file() -> PathBuf {
@@ -149,7 +149,7 @@ pub fn sbx_kit_hash_file() -> PathBuf {
} }
pub fn sbx_vault_mixins_dir() -> PathBuf { pub fn sbx_vault_mixins_dir() -> PathBuf {
cache_path().join(SBX_VAULT_MIXINS_DIR_NAME) cache_dir().join(SBX_VAULT_MIXINS_DIR_NAME)
} }
pub fn sbx_vault_mixins_hash_file() -> PathBuf { pub fn sbx_vault_mixins_hash_file() -> PathBuf {
@@ -157,20 +157,20 @@ pub fn sbx_vault_mixins_hash_file() -> PathBuf {
} }
pub fn sbx_mixin_kits_dir() -> PathBuf { pub fn sbx_mixin_kits_dir() -> PathBuf {
cache_path().join(SBX_MIXIN_KITS_DIR_NAME) cache_dir().join(SBX_MIXIN_KITS_DIR_NAME)
} }
pub fn config_file() -> PathBuf { pub fn config_file() -> PathBuf {
match env::var(get_env_name("config_file")) { match env::var(get_env_name("config_file")) {
Ok(value) => PathBuf::from(value), Ok(value) => PathBuf::from(value),
Err(_) => local_path(CONFIG_FILE_NAME), Err(_) => local_dir(CONFIG_FILE_NAME),
} }
} }
pub fn roles_dir() -> PathBuf { pub fn roles_dir() -> PathBuf {
match env::var(get_env_name("roles_dir")) { match env::var(get_env_name("roles_dir")) {
Ok(value) => PathBuf::from(value), Ok(value) => PathBuf::from(value),
Err(_) => local_path(ROLES_DIR_NAME), Err(_) => local_dir(ROLES_DIR_NAME),
} }
} }
@@ -181,7 +181,7 @@ pub fn role_file(name: &str) -> PathBuf {
pub fn skills_dir() -> PathBuf { pub fn skills_dir() -> PathBuf {
match env::var(get_env_name("skills_dir")) { match env::var(get_env_name("skills_dir")) {
Ok(value) => PathBuf::from(value), Ok(value) => PathBuf::from(value),
Err(_) => local_path(SKILLS_DIR_NAME), Err(_) => local_dir(SKILLS_DIR_NAME),
} }
} }
@@ -193,6 +193,40 @@ pub fn skill_file(name: &str) -> PathBuf {
skill_dir(name).join("SKILL.md") skill_dir(name).join("SKILL.md")
} }
pub fn workspace_config_dir() -> PathBuf {
let workspace_dir_name = match env::var(get_env_name("workspace_config_dir")) {
Ok(value) => value,
Err(_) => WORKSPACE_COYOTE_DIR_NAME.to_string(),
};
env::current_dir()
.unwrap_or_default()
.join(workspace_dir_name)
}
pub fn workspace_skills_dir() -> PathBuf {
workspace_config_dir().join(SKILLS_DIR_NAME)
}
pub fn workspace_skill_file(name: &str) -> PathBuf {
workspace_skills_dir().join(name).join("SKILL.md")
}
pub fn workspace_mcp_config_file() -> Option<PathBuf> {
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
}
fn workspace_mcp_config_file_in(workspace_root: &Path) -> Option<PathBuf> {
let dir = workspace_config_dir();
[
dir.join(MCP_FILE_NAME),
dir.join(HIDDEN_MCP_FILE_NAME),
workspace_root.join(HIDDEN_MCP_FILE_NAME),
]
.into_iter()
.find(|candidate| candidate.is_file())
}
pub fn validate_skill_name(name: &str) -> Result<()> { pub fn validate_skill_name(name: &str) -> Result<()> {
if name.is_empty() { if name.is_empty() {
bail!("Skill name cannot be empty"); bail!("Skill name cannot be empty");
@@ -209,7 +243,7 @@ pub fn validate_skill_name(name: &str) -> Result<()> {
pub fn macros_dir() -> PathBuf { pub fn macros_dir() -> PathBuf {
match env::var(get_env_name("macros_dir")) { match env::var(get_env_name("macros_dir")) {
Ok(value) => PathBuf::from(value), Ok(value) => PathBuf::from(value),
Err(_) => local_path(MACROS_DIR_NAME), Err(_) => local_dir(MACROS_DIR_NAME),
} }
} }
@@ -220,21 +254,21 @@ pub fn macro_file(name: &str) -> PathBuf {
pub fn env_file() -> PathBuf { pub fn env_file() -> PathBuf {
match env::var(get_env_name("env_file")) { match env::var(get_env_name("env_file")) {
Ok(value) => PathBuf::from(value), Ok(value) => PathBuf::from(value),
Err(_) => local_path(ENV_FILE_NAME), Err(_) => local_dir(ENV_FILE_NAME),
} }
} }
pub fn rags_dir() -> PathBuf { pub fn rags_dir() -> PathBuf {
match env::var(get_env_name("rags_dir")) { match env::var(get_env_name("rags_dir")) {
Ok(value) => PathBuf::from(value), Ok(value) => PathBuf::from(value),
Err(_) => local_path(RAGS_DIR_NAME), Err(_) => local_dir(RAGS_DIR_NAME),
} }
} }
pub fn functions_dir() -> PathBuf { pub fn functions_dir() -> PathBuf {
match env::var(get_env_name("functions_dir")) { match env::var(get_env_name("functions_dir")) {
Ok(value) => PathBuf::from(value), Ok(value) => PathBuf::from(value),
Err(_) => local_path(FUNCTIONS_DIR_NAME), Err(_) => local_dir(FUNCTIONS_DIR_NAME),
} }
} }
@@ -259,7 +293,7 @@ pub fn bash_prompt_utils_file() -> PathBuf {
} }
pub fn agents_data_dir() -> PathBuf { pub fn agents_data_dir() -> PathBuf {
local_path(AGENTS_DIR_NAME) local_dir(AGENTS_DIR_NAME)
} }
pub fn agent_data_dir(name: &str) -> PathBuf { pub fn agent_data_dir(name: &str) -> PathBuf {
@@ -305,25 +339,29 @@ pub fn agent_functions_file(name: &str) -> Result<PathBuf> {
} }
pub fn models_override_file() -> PathBuf { pub fn models_override_file() -> PathBuf {
local_path("models-override.yaml") local_dir("models-override.yaml")
} }
pub fn global_memory_dir() -> PathBuf { pub fn global_memory_dir() -> PathBuf {
config_dir().join(MEMORY_DIR_NAME) config_dir().join(MEMORY_DIR_NAME)
} }
pub fn global_memory_index_path() -> PathBuf { pub fn global_memory_index_file() -> PathBuf {
global_memory_dir().join(MEMORY_INDEX_FILE_NAME) global_memory_dir().join(MEMORY_INDEX_FILE_NAME)
} }
pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf { pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf {
workspace_root workspace_root
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME) .join(MEMORY_DIR_NAME)
} }
pub fn workspace_memory_index_file_for(workspace_root: &Path) -> PathBuf {
workspace_memory_dir_for(workspace_root).join(MEMORY_INDEX_FILE_NAME)
}
pub fn repl_history_dir() -> PathBuf { pub fn repl_history_dir() -> PathBuf {
cache_path().join(REPL_HISTORY_DIR_NAME) cache_dir().join(REPL_HISTORY_DIR_NAME)
} }
pub fn repl_history_file(session: &Option<Session>) -> PathBuf { pub fn repl_history_file(session: &Option<Session>) -> PathBuf {
@@ -346,7 +384,7 @@ pub fn log_config() -> Result<(LevelFilter, Option<PathBuf>)> {
}); });
let resolved_log_path = match env::var(get_env_name("log_path")) { let resolved_log_path = match env::var(get_env_name("log_path")) {
Ok(v) => Some(PathBuf::from(v)), Ok(v) => Some(PathBuf::from(v)),
Err(_) => Some(log_path()), Err(_) => Some(log_file()),
}; };
Ok((log_level, resolved_log_path)) Ok((log_level, resolved_log_path))
} }
@@ -405,15 +443,21 @@ pub fn has_macro(name: &str) -> bool {
pub fn list_skills() -> Vec<String> { pub fn list_skills() -> Vec<String> {
let mut names = Vec::new(); let mut names = Vec::new();
if let Ok(rd) = read_dir(skills_dir()) { let mut seen = HashSet::new();
for entry in rd.flatten() {
if let Ok(file_type) = entry.file_type() for dir in [workspace_skills_dir(), skills_dir()] {
&& file_type.is_dir() if let Ok(rd) = read_dir(dir) {
&& let Some(name) = entry.file_name().to_str() for entry in rd.flatten() {
&& entry.path().join("SKILL.md").is_file() if let Ok(file_type) = entry.file_type()
&& validate_skill_name(name).is_ok() && file_type.is_dir()
{ && let Some(name) = entry.file_name().to_str()
names.push(name.to_string()); && !seen.contains(name)
&& entry.path().join("SKILL.md").is_file()
&& validate_skill_name(name).is_ok()
{
seen.insert(name.to_string());
names.push(name.to_string());
}
} }
} }
} }
@@ -423,7 +467,7 @@ pub fn list_skills() -> Vec<String> {
} }
pub fn has_skill(name: &str) -> bool { pub fn has_skill(name: &str) -> bool {
skill_file(name).is_file() workspace_skill_file(name).is_file() || skill_file(name).is_file()
} }
pub fn local_models_override() -> Result<Vec<ProviderModels>> { pub fn local_models_override() -> Result<Vec<ProviderModels>> {
@@ -527,7 +571,7 @@ mod tests {
fn returns_none_when_not_in_sandbox() { fn returns_none_when_not_in_sandbox() {
without_sandbox(|| { without_sandbox(|| {
let p = Path::new("/home/atusa/.coyote_password"); let p = Path::new("/home/atusa/.coyote_password");
assert_eq!(translate_sandboxed_home_path(p), None); assert_eq!(translate_sandboxed_home_dir(p), None);
}); });
} }
@@ -537,7 +581,7 @@ mod tests {
with_sandbox(|| { with_sandbox(|| {
let p = Path::new("/home/atusa/.coyote_password"); let p = Path::new("/home/atusa/.coyote_password");
assert_eq!( assert_eq!(
translate_sandboxed_home_path(p), translate_sandboxed_home_dir(p),
Some(PathBuf::from("/home/agent/.coyote_password")) Some(PathBuf::from("/home/agent/.coyote_password"))
); );
}); });
@@ -545,11 +589,11 @@ mod tests {
#[test] #[test]
#[serial] #[serial]
fn translates_nested_host_home_path() { fn translates_nested_host_home_dir() {
with_sandbox(|| { with_sandbox(|| {
let p = Path::new("/home/atusa/.config/coyote/.password"); let p = Path::new("/home/atusa/.config/coyote/.password");
assert_eq!( assert_eq!(
translate_sandboxed_home_path(p), translate_sandboxed_home_dir(p),
Some(PathBuf::from("/home/agent/.config/coyote/.password")) Some(PathBuf::from("/home/agent/.config/coyote/.password"))
); );
}); });
@@ -560,7 +604,7 @@ mod tests {
fn returns_none_when_path_already_targets_agent_home() { fn returns_none_when_path_already_targets_agent_home() {
with_sandbox(|| { with_sandbox(|| {
let p = Path::new("/home/agent/.coyote_password"); let p = Path::new("/home/agent/.coyote_password");
assert_eq!(translate_sandboxed_home_path(p), None); assert_eq!(translate_sandboxed_home_dir(p), None);
}); });
} }
@@ -569,7 +613,7 @@ mod tests {
fn returns_none_when_path_is_outside_home() { fn returns_none_when_path_is_outside_home() {
with_sandbox(|| { with_sandbox(|| {
let p = Path::new("/etc/coyote/.coyote_password"); let p = Path::new("/etc/coyote/.coyote_password");
assert_eq!(translate_sandboxed_home_path(p), None); assert_eq!(translate_sandboxed_home_dir(p), None);
}); });
} }
@@ -578,7 +622,7 @@ mod tests {
fn returns_none_for_relative_path() { fn returns_none_for_relative_path() {
with_sandbox(|| { with_sandbox(|| {
let p = Path::new(".coyote_password"); let p = Path::new(".coyote_password");
assert_eq!(translate_sandboxed_home_path(p), None); assert_eq!(translate_sandboxed_home_dir(p), None);
}); });
} }
@@ -587,17 +631,17 @@ mod tests {
fn returns_none_for_first_segment_not_home() { fn returns_none_for_first_segment_not_home() {
with_sandbox(|| { with_sandbox(|| {
let p = Path::new("/opt/atusa/.coyote_password"); let p = Path::new("/opt/atusa/.coyote_password");
assert_eq!(translate_sandboxed_home_path(p), None); assert_eq!(translate_sandboxed_home_dir(p), None);
}); });
} }
#[test] #[test]
#[serial] #[serial]
fn translates_macos_users_path() { fn translates_macos_users_dir() {
with_sandbox(|| { with_sandbox(|| {
let p = Path::new("/Users/atusa/.coyote_password"); let p = Path::new("/Users/atusa/.coyote_password");
assert_eq!( assert_eq!(
translate_sandboxed_home_path(p), translate_sandboxed_home_dir(p),
Some(PathBuf::from("/home/agent/.coyote_password")) Some(PathBuf::from("/home/agent/.coyote_password"))
); );
}); });
@@ -605,11 +649,11 @@ mod tests {
#[test] #[test]
#[serial] #[serial]
fn translates_macos_nested_path() { fn translates_macos_nested_dir() {
with_sandbox(|| { with_sandbox(|| {
let p = Path::new("/Users/atusa/.config/coyote/.password"); let p = Path::new("/Users/atusa/.config/coyote/.password");
assert_eq!( assert_eq!(
translate_sandboxed_home_path(p), translate_sandboxed_home_dir(p),
Some(PathBuf::from("/home/agent/.config/coyote/.password")) Some(PathBuf::from("/home/agent/.config/coyote/.password"))
); );
}); });
@@ -617,10 +661,10 @@ mod tests {
#[test] #[test]
#[serial] #[serial]
fn returns_none_when_macos_path_already_targets_agent() { fn returns_none_when_macos_dir_already_targets_agent() {
with_sandbox(|| { with_sandbox(|| {
let p = Path::new("/Users/agent/.coyote_password"); let p = Path::new("/Users/agent/.coyote_password");
assert_eq!(translate_sandboxed_home_path(p), None); assert_eq!(translate_sandboxed_home_dir(p), None);
}); });
} }
@@ -630,7 +674,7 @@ mod tests {
with_sandbox(|| { with_sandbox(|| {
let p = Path::new("C:\\Users\\atusa\\.coyote_password"); let p = Path::new("C:\\Users\\atusa\\.coyote_password");
assert_eq!( assert_eq!(
translate_sandboxed_home_path(p), translate_sandboxed_home_dir(p),
Some(PathBuf::from("/home/agent/.coyote_password")) Some(PathBuf::from("/home/agent/.coyote_password"))
); );
}); });
@@ -642,7 +686,7 @@ mod tests {
with_sandbox(|| { with_sandbox(|| {
let p = Path::new("D:\\Users\\atusa\\.config\\coyote\\.password"); let p = Path::new("D:\\Users\\atusa\\.config\\coyote\\.password");
assert_eq!( assert_eq!(
translate_sandboxed_home_path(p), translate_sandboxed_home_dir(p),
Some(PathBuf::from("/home/agent/.config/coyote/.password")) Some(PathBuf::from("/home/agent/.config/coyote/.password"))
); );
}); });
@@ -653,7 +697,105 @@ mod tests {
fn returns_none_when_windows_path_already_targets_agent() { fn returns_none_when_windows_path_already_targets_agent() {
with_sandbox(|| { with_sandbox(|| {
let p = Path::new("C:\\Users\\agent\\.coyote_password"); let p = Path::new("C:\\Users\\agent\\.coyote_password");
assert_eq!(translate_sandboxed_home_path(p), None); assert_eq!(translate_sandboxed_home_dir(p), None);
});
}
}
mod workspace_mcp_resolution {
use super::*;
use serial_test::serial;
fn with_workspace_dir<F: FnOnce(&Path, &Path)>(f: F) {
let unique = time::SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = env::temp_dir().join(format!("coyote-workspace-mcp-test-{unique}"));
let ws_dir = root.join(WORKSPACE_COYOTE_DIR_NAME);
fs::create_dir_all(&ws_dir).unwrap();
let env_name = get_env_name("workspace_config_dir");
let prev = env::var_os(&env_name);
unsafe {
env::set_var(&env_name, &ws_dir);
}
f(&root, &ws_dir);
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
let _ = fs::remove_dir_all(&root);
}
#[test]
#[serial]
fn returns_none_when_no_config_exists() {
with_workspace_dir(|root, _| {
assert_eq!(workspace_mcp_config_file_in(root), None);
});
}
#[test]
#[serial]
fn finds_mcp_json() {
with_workspace_dir(|root, ws_dir| {
fs::write(ws_dir.join("mcp.json"), "{}").unwrap();
assert_eq!(
workspace_mcp_config_file_in(root),
Some(ws_dir.join("mcp.json"))
);
});
}
#[test]
#[serial]
fn falls_back_to_claude_style_hidden_mcp_json() {
with_workspace_dir(|root, ws_dir| {
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
assert_eq!(
workspace_mcp_config_file_in(root),
Some(ws_dir.join(".mcp.json"))
);
});
}
#[test]
#[serial]
fn prefers_mcp_json_when_both_exist() {
with_workspace_dir(|root, ws_dir| {
fs::write(ws_dir.join("mcp.json"), "{}").unwrap();
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
assert_eq!(
workspace_mcp_config_file_in(root),
Some(ws_dir.join("mcp.json"))
);
});
}
#[test]
#[serial]
fn falls_back_to_project_root_hidden_mcp_json() {
with_workspace_dir(|root, _| {
fs::write(root.join(".mcp.json"), "{}").unwrap();
assert_eq!(
workspace_mcp_config_file_in(root),
Some(root.join(".mcp.json"))
);
});
}
#[test]
#[serial]
fn prefers_workspace_dir_config_over_project_root() {
with_workspace_dir(|root, ws_dir| {
fs::write(ws_dir.join(".mcp.json"), "{}").unwrap();
fs::write(root.join(".mcp.json"), "{}").unwrap();
assert_eq!(
workspace_mcp_config_file_in(root),
Some(ws_dir.join(".mcp.json"))
);
}); });
} }
} }
+2 -1
View File
@@ -84,7 +84,8 @@ pub(in crate::config) const DEFAULT_SPAWN_INSTRUCTIONS: &str = indoc! {"
| `agent__spawn` | Spawn a subagent in the background. Returns an `id` immediately. | | `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 check: is the agent done yet? Returns PENDING or result. |
| `agent__collect` | Blocking wait: wait for an agent to finish, return its output. | | `agent__collect` | Blocking wait: wait for an agent to finish, return its output. |
| `agent__list` | List all spawned agents and their status. | | `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. |
| `agent__cancel` | Cancel a running agent by ID. | | `agent__cancel` | Cancel a running agent by ID. |
| `agent__task_create` | Create a task in the dependency-aware task queue. | | `agent__task_create` | Create a task in the dependency-aware task queue. |
| `agent__task_list` | List all tasks and their status/dependencies. | | `agent__task_list` | List all tasks and their status/dependencies. |
File diff suppressed because it is too large Load Diff
+24
View File
@@ -32,7 +32,9 @@ pub trait RoleLike {
fn enabled_mcp_servers(&self) -> Option<Vec<String>>; fn enabled_mcp_servers(&self) -> Option<Vec<String>>;
fn set_model(&mut self, model: Model); fn set_model(&mut self, model: Model);
fn set_temperature(&mut self, value: Option<f64>); fn set_temperature(&mut self, value: Option<f64>);
fn reasoning_effort(&self) -> Option<String>;
fn set_top_p(&mut self, value: Option<f64>); fn set_top_p(&mut self, value: Option<f64>);
fn set_reasoning_effort(&mut self, value: Option<String>);
fn set_enabled_tools(&mut self, value: Option<Vec<String>>); fn set_enabled_tools(&mut self, value: Option<Vec<String>>);
fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>); fn set_enabled_mcp_servers(&mut self, value: Option<Vec<String>>);
} }
@@ -51,6 +53,8 @@ pub struct Role {
temperature: Option<f64>, temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f64>, top_p: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
#[serde( #[serde(
default, default,
skip_serializing_if = "Option::is_none", skip_serializing_if = "Option::is_none",
@@ -116,6 +120,9 @@ impl Role {
"model" => role.model_id = value.as_str().map(|v| v.to_string()), "model" => role.model_id = value.as_str().map(|v| v.to_string()),
"temperature" => role.temperature = value.as_f64(), "temperature" => role.temperature = value.as_f64(),
"top_p" => role.top_p = value.as_f64(), "top_p" => role.top_p = value.as_f64(),
"reasoning_effort" => {
role.reasoning_effort = value.as_str().map(|v| v.to_string())
}
"enabled_tools" => role.enabled_tools = parse_string_or_array(value), "enabled_tools" => role.enabled_tools = parse_string_or_array(value),
"enabled_mcp_servers" => { "enabled_mcp_servers" => {
role.enabled_mcp_servers = parse_string_or_array(value) role.enabled_mcp_servers = parse_string_or_array(value)
@@ -170,6 +177,9 @@ impl Role {
if let Some(top_p) = self.top_p() { if let Some(top_p) = self.top_p() {
metadata.push(format!("top_p: {top_p}")); metadata.push(format!("top_p: {top_p}"));
} }
if let Some(reasoning_effort) = self.reasoning_effort() {
metadata.push(format!("reasoning_effort: {reasoning_effort}"));
}
if let Some(enabled_tools) = &self.enabled_tools { if let Some(enabled_tools) = &self.enabled_tools {
let inline = serde_json::to_string(enabled_tools).unwrap_or_else(|_| "[]".to_string()); let inline = serde_json::to_string(enabled_tools).unwrap_or_else(|_| "[]".to_string());
metadata.push(format!("enabled_tools: {inline}")); metadata.push(format!("enabled_tools: {inline}"));
@@ -245,12 +255,14 @@ impl Role {
pub fn sync<T: RoleLike>(&mut self, role_like: &T) { pub fn sync<T: RoleLike>(&mut self, role_like: &T) {
let model = role_like.model(); let model = role_like.model();
let reasoning_effort = role_like.reasoning_effort();
let temperature = role_like.temperature(); let temperature = role_like.temperature();
let top_p = role_like.top_p(); let top_p = role_like.top_p();
let enabled_tools = role_like.enabled_tools(); let enabled_tools = role_like.enabled_tools();
let enabled_mcp_servers = role_like.enabled_mcp_servers(); let enabled_mcp_servers = role_like.enabled_mcp_servers();
self.batch_set( self.batch_set(
model, model,
reasoning_effort,
temperature, temperature,
top_p, top_p,
enabled_tools, enabled_tools,
@@ -261,12 +273,16 @@ impl Role {
pub fn batch_set( pub fn batch_set(
&mut self, &mut self,
model: &Model, model: &Model,
reasoning_effort: Option<String>,
temperature: Option<f64>, temperature: Option<f64>,
top_p: Option<f64>, top_p: Option<f64>,
enabled_tools: Option<Vec<String>>, enabled_tools: Option<Vec<String>>,
enabled_mcp_servers: Option<Vec<String>>, enabled_mcp_servers: Option<Vec<String>>,
) { ) {
self.set_model(model.clone()); self.set_model(model.clone());
if reasoning_effort.is_some() {
self.set_reasoning_effort(reasoning_effort.clone());
}
if temperature.is_some() { if temperature.is_some() {
self.set_temperature(temperature); self.set_temperature(temperature);
} }
@@ -410,6 +426,10 @@ impl RoleLike for Role {
self.top_p self.top_p
} }
fn reasoning_effort(&self) -> Option<String> {
self.reasoning_effort.clone()
}
fn enabled_tools(&self) -> Option<Vec<String>> { fn enabled_tools(&self) -> Option<Vec<String>> {
self.enabled_tools.clone() self.enabled_tools.clone()
} }
@@ -433,6 +453,10 @@ impl RoleLike for Role {
self.top_p = value; self.top_p = value;
} }
fn set_reasoning_effort(&mut self, value: Option<String>) {
self.reasoning_effort = value;
}
fn set_enabled_tools(&mut self, value: Option<Vec<String>>) { fn set_enabled_tools(&mut self, value: Option<Vec<String>>) {
self.enabled_tools = value; self.enabled_tools = value;
} }
+56 -6
View File
@@ -24,6 +24,8 @@ pub struct Session {
temperature: Option<f64>, temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f64>, top_p: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
#[serde( #[serde(
default, default,
skip_serializing_if = "Option::is_none", skip_serializing_if = "Option::is_none",
@@ -175,6 +177,14 @@ impl Session {
&self.name &self.name
} }
pub fn set_name(&mut self, name: String) {
self.name = name;
}
pub fn clear_autoname(&mut self) {
self.autoname = None;
}
pub fn role_name(&self) -> Option<&str> { pub fn role_name(&self) -> Option<&str> {
self.role_name.as_deref() self.role_name.as_deref()
} }
@@ -261,7 +271,7 @@ impl Session {
data["messages"] = json!(self.messages); data["messages"] = json!(self.messages);
let output = serde_yaml::to_string(&data) let output = serde_yaml::to_string(&data)
.with_context(|| format!("Unable to show info about session '{}'", &self.name))?; .with_context(|| format!("Unable to show info about session '{}'", self.name))?;
Ok(output) Ok(output)
} }
@@ -358,14 +368,24 @@ impl Session {
for message in &self.messages { for message in &self.messages {
match message.role { match message.role {
MessageRole::System => { MessageRole::System => {
lines.push( let body = render
render .render(&message.content.render_input(resolve_url_fn, agent_info));
.render(&message.content.render_input(resolve_url_fn, agent_info)), let tail = render.finalize();
); if tail.is_empty() {
lines.push(body);
} else {
lines.push(format!("{body}\n{tail}"));
}
} }
MessageRole::Assistant => { MessageRole::Assistant => {
if let MessageContent::Text(text) = &message.content { if let MessageContent::Text(text) = &message.content {
lines.push(render.render(text)); let body = render.render(text);
let tail = render.finalize();
if tail.is_empty() {
lines.push(body);
} else {
lines.push(format!("{body}\n{tail}"));
}
} }
lines.push("".into()); lines.push("".into());
} }
@@ -401,6 +421,7 @@ impl Session {
self.model_id = role.model().id(); self.model_id = role.model().id();
self.temperature = role.temperature(); self.temperature = role.temperature();
self.top_p = role.top_p(); self.top_p = role.top_p();
self.reasoning_effort = role.reasoning_effort();
self.enabled_tools = role.enabled_tools(); self.enabled_tools = role.enabled_tools();
self.enabled_mcp_servers = role.enabled_mcp_servers(); self.enabled_mcp_servers = role.enabled_mcp_servers();
self.model = role.model().clone(); self.model = role.model().clone();
@@ -732,6 +753,15 @@ impl Session {
self.update_tokens(); self.update_tokens();
} }
pub fn pop_last_exchange(&mut self) -> Option<String> {
let user_idx = self.messages.iter().rposition(|m| m.role.is_user())?;
let user_text = self.messages[user_idx].content.as_text()?.to_string();
self.messages.truncate(user_idx);
self.dirty = true;
self.update_tokens();
Some(user_text)
}
pub fn echo_messages(&self, input: &Input) -> String { pub fn echo_messages(&self, input: &Input) -> String {
let messages = self.build_messages(input); let messages = self.build_messages(input);
serde_yaml::to_string(&messages).unwrap_or_else(|_| "Unable to echo message".into()) serde_yaml::to_string(&messages).unwrap_or_else(|_| "Unable to echo message".into())
@@ -783,6 +813,10 @@ impl RoleLike for Session {
self.top_p self.top_p
} }
fn reasoning_effort(&self) -> Option<String> {
self.reasoning_effort.clone()
}
fn enabled_tools(&self) -> Option<Vec<String>> { fn enabled_tools(&self) -> Option<Vec<String>> {
self.enabled_tools.clone() self.enabled_tools.clone()
} }
@@ -814,6 +848,13 @@ impl RoleLike for Session {
} }
} }
fn set_reasoning_effort(&mut self, value: Option<String>) {
if self.reasoning_effort != value {
self.reasoning_effort = value;
self.dirty = true;
}
}
fn set_enabled_tools(&mut self, value: Option<Vec<String>>) { fn set_enabled_tools(&mut self, value: Option<Vec<String>>) {
if self.enabled_tools != value { if self.enabled_tools != value {
self.enabled_tools = value; self.enabled_tools = value;
@@ -1023,4 +1064,13 @@ mod tests {
session.set_autonaming(true); session.set_autonaming(true);
assert!(!session.need_autoname()); assert!(!session.need_autoname());
} }
#[test]
fn session_set_name_updates_name() {
let mut session = Session::default();
session.set_name("my-fork".to_string());
assert_eq!(session.name(), "my-fork");
}
} }
+5 -1
View File
@@ -117,7 +117,11 @@ impl Skill {
pub fn load(name: &str) -> Result<Self> { pub fn load(name: &str) -> Result<Self> {
paths::validate_skill_name(name)?; paths::validate_skill_name(name)?;
let path = paths::skill_file(name); let path = if paths::workspace_skill_file(name).is_file() {
paths::workspace_skill_file(name)
} else {
paths::skill_file(name)
};
let content = read_to_string(&path) let content = read_to_string(&path)
.with_context(|| format!("Failed to read skill '{name}' at {}", path.display()))?; .with_context(|| format!("Failed to read skill '{name}' at {}", path.display()))?;
Ok(Skill::new(name, &content)) Ok(Skill::new(name, &content))
+20 -34
View File
@@ -321,7 +321,7 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value
Ok(json!({ Ok(json!({
"files": entries, "files": entries,
"global_index_exists": paths::global_memory_index_path().exists(), "global_index_exists": paths::global_memory_index_file().exists(),
"workspace": store.workspace.as_ref().map(workspace_label), "workspace": store.workspace.as_ref().map(workspace_label),
})) }))
} }
@@ -474,7 +474,7 @@ fn rename_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value>
let description = renamed.frontmatter.description.clone().unwrap_or_default(); let description = renamed.frontmatter.description.clone().unwrap_or_default();
ensure_index_entry(&index_path, &new_name, &description)?; ensure_index_entry(&index_path, &new_name, &description)?;
// Other indexes (other scope's MEMORY.md, lite COYOTE.md): rewrite wikilinks only. // Other indexes (other scope's MEMORY.md): rewrite wikilinks only.
for other_index in other_index_paths(store, &target_dir) { for other_index in other_index_paths(store, &target_dir) {
if let Ok(existing) = fs::read_to_string(&other_index) if let Ok(existing) = fs::read_to_string(&other_index)
&& existing.contains(&needle) && existing.contains(&needle)
@@ -539,17 +539,11 @@ fn other_index_paths(store: &MemoryStore, own_dir: &Path) -> Vec<PathBuf> {
out.push(global_index); out.push(global_index);
} }
match &store.workspace { if let Some(ws) = &store.workspace {
Some(WorkspaceMemory::Structured { dir, .. }) => { let index = ws.dir.join("MEMORY.md");
let index = dir.join("MEMORY.md"); if ws.dir.as_path() != own_dir && index.exists() {
if dir.as_path() != own_dir && index.exists() { out.push(index);
out.push(index);
}
} }
Some(WorkspaceMemory::Lite { file, .. }) if file.exists() => {
out.push(file.clone());
}
_ => {}
} }
out out
@@ -637,10 +631,7 @@ fn find_file(store: &MemoryStore, name: &str) -> Result<Option<MemoryFile>> {
fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result<PathBuf> { fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result<PathBuf> {
match &store.workspace { match &store.workspace {
Some(WorkspaceMemory::Structured { dir, .. }) => Ok(dir.clone()), Some(ws) => Ok(ws.dir.clone()),
Some(WorkspaceMemory::Lite { workspace_root, .. }) => {
Ok(paths::workspace_memory_dir_for(workspace_root))
}
None => match find_git_root(cwd) { None => match find_git_root(cwd) {
Some(git_root) => bootstrap_workspace_memory(&git_root), Some(git_root) => bootstrap_workspace_memory(&git_root),
None => bail!( None => bail!(
@@ -652,20 +643,10 @@ fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result<PathBuf> {
} }
fn workspace_label(w: &WorkspaceMemory) -> Value { fn workspace_label(w: &WorkspaceMemory) -> Value {
match w { json!({
WorkspaceMemory::Structured { workspace_root, .. } => json!({ "root": w.workspace_root.display().to_string(),
"mode": "structured", "dir": w.dir.display().to_string(),
"root": workspace_root.display().to_string(), })
}),
WorkspaceMemory::Lite {
workspace_root,
file,
} => json!({
"mode": "lite",
"root": workspace_root.display().to_string(),
"file": file.display().to_string(),
}),
}
} }
fn lint_memory(store: &MemoryStore) -> Result<Value> { fn lint_memory(store: &MemoryStore) -> Result<Value> {
@@ -872,19 +853,24 @@ mod tests {
} }
#[test] #[test]
fn workspace_write_dir_promotes_lite_to_structured_subdir() { fn workspace_write_dir_treats_root_instructions_file_as_no_memory() {
let root = temp_root("ws_lite_promote"); let root = temp_root("ws_instructions_only");
let workspace = root.join("ws"); let workspace = root.join("ws");
fs::create_dir_all(&workspace).unwrap(); fs::create_dir_all(workspace.join(".git")).unwrap();
fs::write(workspace.join("COYOTE.md"), "lite").unwrap(); fs::write(workspace.join("COYOTE.md"), "instructions, not memory").unwrap();
let store = MemoryStore { let store = MemoryStore {
global_dir: root.join("g"), global_dir: root.join("g"),
workspace: discover_workspace_memory(&workspace), workspace: discover_workspace_memory(&workspace),
}; };
assert!(store.workspace.is_none(), "COYOTE.md must not be memory");
let dir = workspace_write_dir(&store, &workspace).unwrap(); let dir = workspace_write_dir(&store, &workspace).unwrap();
assert_eq!(dir, workspace.join(".coyote").join("memory")); assert_eq!(dir, workspace.join(".coyote").join("memory"));
assert!(
dir.join("MEMORY.md").exists(),
"bootstrap must create index"
);
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
} }
+78 -20
View File
@@ -5,6 +5,7 @@ pub(crate) mod todo;
pub(crate) mod user_interaction; pub(crate) mod user_interaction;
use crate::{ use crate::{
client::ThinkingBlock,
config::{Agent, RequestContext}, config::{Agent, RequestContext},
graph, graph,
utils::*, utils::*,
@@ -144,29 +145,19 @@ pub async fn eval_tool_calls(
if calls.is_empty() { if calls.is_empty() {
bail!("The request was aborted because an infinite loop of function calls was detected.") bail!("The request was aborted because an infinite loop of function calls was detected.")
} }
let mut is_all_null = true;
for call in calls { for call in calls {
if let Some(msg) = ctx.tool_scope.tool_tracker.check_loop(&call.clone()) { if let Some(msg) = ctx.tool_scope.tool_tracker.check_loop(&call.clone()) {
let dup_msg = format!("{{\"tool_call_loop_alert\":{}}}", &msg.trim()); let dup_msg = format!("{{\"tool_call_loop_alert\":{}}}", msg.trim());
println!( println!(
"{}", "{}",
warning_text(format!("{}: ⚠️ Tool-call loop detected! ⚠️", &call.name).as_str()) warning_text(format!("{}: ⚠️ Tool-call loop detected! ⚠️", call.name).as_str())
); );
let val = json!(dup_msg); let val = json!(dup_msg);
output.push(ToolResult::new(call, val)); output.push(ToolResult::new(call, val));
is_all_null = false;
continue; continue;
} }
let mut result = call.eval(ctx).await?; let result = call.eval(ctx).await?;
if result.is_null() { output.push(ToolResult::new(call, normalize_tool_result(result)));
result = json!("DONE");
} else {
is_all_null = false;
}
output.push(ToolResult::new(call, result));
}
if is_all_null {
output = vec![];
} }
if !output.is_empty() { if !output.is_empty() {
@@ -196,15 +187,37 @@ pub async fn eval_tool_calls(
Ok(output) Ok(output)
} }
/// Tools that succeed silently (e.g. `mkdir -p` via execute_command) evaluate to
/// `Null`. Substitute a concrete `"DONE"` marker so every call produces a
/// `ToolResult`: agentic loops (graph llm nodes, spawned agents, the REPL) treat
/// an empty `tool_results` as "the LLM concluded", so dropping silent results
/// would prematurely terminate a turn that called only silent tools.
fn normalize_tool_result(result: Value) -> Value {
if result.is_null() {
json!("DONE")
} else {
result
}
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ToolResult { pub struct ToolResult {
pub call: ToolCall, pub call: ToolCall,
pub output: Value, pub output: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub thinking: Vec<ThinkingBlock>,
} }
impl ToolResult { impl ToolResult {
pub fn new(call: ToolCall, output: Value) -> Self { pub fn new(call: ToolCall, output: Value) -> Self {
Self { call, output } Self {
call,
output,
text: None,
thinking: vec![],
}
} }
} }
@@ -730,7 +743,7 @@ impl Functions {
let root_dir = paths::functions_dir(); let root_dir = paths::functions_dir();
let tool_path = format!( let tool_path = format!(
"{}/{binary_name}", "{}/{binary_name}",
&paths::global_tools_dir().to_string_lossy() paths::global_tools_dir().to_string_lossy()
); );
content_template content_template
.replace("{function_name}", binary_name) .replace("{function_name}", binary_name)
@@ -741,7 +754,7 @@ impl Functions {
let root_dir = paths::agent_data_dir(agent_name); let root_dir = paths::agent_data_dir(agent_name);
let tool_path = format!( let tool_path = format!(
"{}/{binary_name}", "{}/{binary_name}",
&paths::global_tools_dir().to_string_lossy() paths::global_tools_dir().to_string_lossy()
); );
content_template content_template
.replace("{function_name}", binary_name) .replace("{function_name}", binary_name)
@@ -870,7 +883,7 @@ impl Functions {
let root_dir = paths::functions_dir(); let root_dir = paths::functions_dir();
let tool_path = format!( let tool_path = format!(
"{}/{binary_name}", "{}/{binary_name}",
&paths::global_tools_dir().to_string_lossy() paths::global_tools_dir().to_string_lossy()
); );
content_template content_template
.replace("{function_name}", binary_name) .replace("{function_name}", binary_name)
@@ -881,7 +894,7 @@ impl Functions {
let root_dir = paths::agent_data_dir(agent_name); let root_dir = paths::agent_data_dir(agent_name);
let tool_path = format!( let tool_path = format!(
"{}/{binary_name}", "{}/{binary_name}",
&paths::global_tools_dir().to_string_lossy() paths::global_tools_dir().to_string_lossy()
); );
content_template content_template
.replace("{function_name}", binary_name) .replace("{function_name}", binary_name)
@@ -1527,6 +1540,21 @@ mod tests {
ToolCall::new(name.to_string(), args, Some("id1".to_string())) ToolCall::new(name.to_string(), args, Some("id1".to_string()))
} }
#[test]
fn normalize_tool_result_substitutes_done_for_null() {
assert_eq!(normalize_tool_result(Value::Null), json!("DONE"));
}
#[test]
fn normalize_tool_result_preserves_non_null_values() {
assert_eq!(
normalize_tool_result(json!({"output": "hi"})),
json!({"output": "hi"})
);
assert_eq!(normalize_tool_result(json!("")), json!(""));
assert_eq!(normalize_tool_result(json!(false)), json!(false));
}
#[test] #[test]
fn toolcall_new_sets_fields() { fn toolcall_new_sets_fields() {
let tc = ToolCall::new("my_tool".into(), json!({"x": 1}), Some("call-1".into())); let tc = ToolCall::new("my_tool".into(), json!({"x": 1}), Some("call-1".into()));
@@ -1765,7 +1793,8 @@ mod tests {
assert!(f.contains("agent__spawn")); assert!(f.contains("agent__spawn"));
assert!(f.contains("agent__check")); assert!(f.contains("agent__check"));
assert!(f.contains("agent__collect")); assert!(f.contains("agent__collect"));
assert!(f.contains("agent__list")); assert!(f.contains("agent__list_running"));
assert!(f.contains("agent__list_available"));
assert!(f.contains("agent__cancel")); assert!(f.contains("agent__cancel"));
assert!(f.contains("agent__reply_escalation")); assert!(f.contains("agent__reply_escalation"));
} }
@@ -1890,4 +1919,33 @@ mod tests {
assert_eq!(result.call.name, "my_tool"); assert_eq!(result.call.name, "my_tool");
assert_eq!(result.output, json!({"result": "ok"})); assert_eq!(result.output, json!({"result": "ok"}));
} }
#[test]
fn thinking_block_matches_anthropic_wire_format() {
let block = ThinkingBlock::Thinking {
thinking: "chain of thought".to_string(),
signature: "sig123".to_string(),
};
assert_eq!(
serde_json::to_value(&block).unwrap(),
json!({"type": "thinking", "thinking": "chain of thought", "signature": "sig123"})
);
let redacted = ThinkingBlock::RedactedThinking {
data: "opaque".to_string(),
};
assert_eq!(
serde_json::to_value(&redacted).unwrap(),
json!({"type": "redacted_thinking", "data": "opaque"})
);
}
#[test]
fn tool_result_deserializes_without_text_and_thinking() {
let yaml = "call:\n name: my_tool\n arguments: {}\noutput: ok\n";
let result: ToolResult = serde_yaml::from_str(yaml).unwrap();
assert_eq!(result.call.name, "my_tool");
assert!(result.text.is_none());
assert!(result.thinking.is_empty());
}
} }
+140 -14
View File
@@ -1,6 +1,8 @@
use super::{FunctionDeclaration, JsonSchema}; use super::{FunctionDeclaration, JsonSchema};
use crate::client::{Model, ModelType, call_chat_completions}; use crate::client::{Model, ModelType, call_chat_completions};
use crate::config::{Agent, AppState, Input, RequestContext, Role, RoleLike}; use crate::config::{
Agent, AppState, Input, RequestContext, Role, RoleLike, list_agents_with_descriptions,
};
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox}; use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox};
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor}; use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor};
use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal}; use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal};
@@ -23,6 +25,13 @@ pub const SUPERVISOR_FUNCTION_PREFIX: &str = "agent__";
pub const PENDING_AGENTS_GUARDRAIL_MAX: u32 = 3; pub const PENDING_AGENTS_GUARDRAIL_MAX: u32 = 3;
fn agent_permitted(whitelist: Option<&[String]>, target: &str) -> bool {
match whitelist {
None => true,
Some(w) => w.iter().any(|a| a == target),
}
}
pub enum GuardrailAction { pub enum GuardrailAction {
NoAction, NoAction,
Inject(String), Inject(String),
@@ -193,8 +202,23 @@ pub fn supervisor_function_declarations() -> Vec<FunctionDeclaration> {
agent: false, agent: false,
}, },
FunctionDeclaration { FunctionDeclaration {
name: format!("{SUPERVISOR_FUNCTION_PREFIX}list"), name: format!("{SUPERVISOR_FUNCTION_PREFIX}list_running"),
description: "List all currently running subagents and their status.".to_string(), description: "List all subagents YOU have spawned that are still tracked by the supervisor, with their \
status. Use this to see which of your background agents are still active. To discover which \
agent types you can spawn in the first place, use `agent__list_available` instead.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::new()),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{SUPERVISOR_FUNCTION_PREFIX}list_available"),
description: "List all agent types installed and available to spawn (name + description). Use this to \
discover what specialists exist before calling `agent__spawn` especially when you're unsure \
which agent to delegate to. This is the discovery counterpart to `agent__list_running` \
(which reports agents you have already spawned).".to_string(),
parameters: JsonSchema { parameters: JsonSchema {
type_value: Some("object".to_string()), type_value: Some("object".to_string()),
properties: Some(IndexMap::new()), properties: Some(IndexMap::new()),
@@ -384,7 +408,8 @@ pub async fn handle_supervisor_tool(
"spawn" => handle_spawn(ctx, args).await, "spawn" => handle_spawn(ctx, args).await,
"check" => handle_check(ctx, args).await, "check" => handle_check(ctx, args).await,
"collect" => handle_collect(ctx, args).await, "collect" => handle_collect(ctx, args).await,
"list" => handle_list(ctx), "list_running" => handle_list_running(ctx),
"list_available" => handle_list_available(ctx),
"cancel" => handle_cancel(ctx, args).await, "cancel" => handle_cancel(ctx, args).await,
"send_message" => handle_send_message(ctx, args), "send_message" => handle_send_message(ctx, args),
"check_inbox" => handle_check_inbox(ctx), "check_inbox" => handle_check_inbox(ctx),
@@ -624,6 +649,18 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
.to_string(); .to_string();
let _task_id = args.get("task_id").and_then(Value::as_str); let _task_id = args.get("task_id").and_then(Value::as_str);
if let Some(parent) = ctx.agent.as_ref()
&& !agent_permitted(parent.spawnable_agents(), &agent_name)
{
let whitelist = parent.spawnable_agents().unwrap_or_default();
return Ok(json!({
"status": "error",
"message": format!(
"Agent '{agent_name}' is not in this agent's `spawnable_agents` whitelist. Allowed: {whitelist:?}. Call `agent__list_available` to see what you can spawn."
),
}));
}
let short_uuid = &Uuid::new_v4().to_string()[..8]; let short_uuid = &Uuid::new_v4().to_string()[..8];
let agent_id = format!("agent_{agent_name}_{short_uuid}"); let agent_id = format!("agent_{agent_name}_{short_uuid}");
@@ -920,7 +957,7 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
} }
} }
fn handle_list(ctx: &mut RequestContext) -> Result<Value> { fn handle_list_running(ctx: &mut RequestContext) -> Result<Value> {
let supervisor = ctx let supervisor = ctx
.supervisor .supervisor
.as_ref() .as_ref()
@@ -948,6 +985,35 @@ fn handle_list(ctx: &mut RequestContext) -> Result<Value> {
})) }))
} }
fn handle_list_available(ctx: &RequestContext) -> Result<Value> {
let whitelist: Option<Vec<String>> = ctx
.agent
.as_ref()
.and_then(|a| a.spawnable_agents())
.map(<[String]>::to_vec);
let entries: Vec<(String, String)> = list_agents_with_descriptions()
.into_iter()
.filter(|(name, _)| agent_permitted(whitelist.as_deref(), name))
.collect();
let count = entries.len();
let agents: Vec<Value> = entries
.into_iter()
.map(|(name, description)| {
if description.is_empty() {
json!({ "name": name })
} else {
json!({ "name": name, "description": description })
}
})
.collect();
Ok(json!({
"count": count,
"agents": agents,
}))
}
async fn handle_cancel(ctx: &mut RequestContext, args: &Value) -> Result<Value> { async fn handle_cancel(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let id = args let id = args
.get("id") .get("id")
@@ -1434,32 +1500,75 @@ mod tests {
} }
#[test] #[test]
fn handle_list_empty_supervisor() { fn handle_list_running_empty_supervisor() {
let mut ctx = ctx_with_supervisor(4, 3); let mut ctx = ctx_with_supervisor(4, 3);
let result = handle_list(&mut ctx).unwrap(); let result = handle_list_running(&mut ctx).unwrap();
assert_eq!(result["active_count"], 0); assert_eq!(result["active_count"], 0);
assert_eq!(result["max_concurrent"], 4); assert_eq!(result["max_concurrent"], 4);
assert!(result["agents"].as_array().unwrap().is_empty()); assert!(result["agents"].as_array().unwrap().is_empty());
} }
#[test] #[test]
fn handle_list_with_agents() { fn handle_list_running_with_agents() {
let mut ctx = ctx_with_supervisor(4, 3); let mut ctx = ctx_with_supervisor(4, 3);
register_fake_agent(&mut ctx, "a1", "explore"); register_fake_agent(&mut ctx, "a1", "explore");
register_fake_agent(&mut ctx, "a2", "coder"); register_fake_agent(&mut ctx, "a2", "coder");
let result = handle_list(&mut ctx).unwrap(); let result = handle_list_running(&mut ctx).unwrap();
assert_eq!(result["active_count"], 2); assert_eq!(result["active_count"], 2);
let agents = result["agents"].as_array().unwrap(); let agents = result["agents"].as_array().unwrap();
assert_eq!(agents.len(), 2); assert_eq!(agents.len(), 2);
} }
#[test] #[test]
fn handle_list_no_supervisor_errors() { fn handle_list_running_no_supervisor_errors() {
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
let result = handle_list(&mut ctx); let result = handle_list_running(&mut ctx);
assert!(result.is_err()); assert!(result.is_err());
} }
#[test]
fn handle_list_available_returns_shape() {
let ctx = ctx_with_supervisor(4, 3);
let result = handle_list_available(&ctx).unwrap();
assert!(result["count"].is_number());
assert!(result["agents"].is_array());
}
#[test]
fn handle_list_available_unrestricted_when_no_whitelist() {
let ctx = ctx_with_supervisor(4, 3);
let result = handle_list_available(&ctx).unwrap();
let full_count = result["count"].as_u64().unwrap();
assert_eq!(full_count as usize, list_agents_with_descriptions().len());
}
#[test]
fn agent_permitted_none_whitelist_allows_all() {
assert!(agent_permitted(None, "explore"));
assert!(agent_permitted(None, "anything"));
}
#[test]
fn agent_permitted_empty_whitelist_denies_all() {
let empty: Vec<String> = vec![];
assert!(!agent_permitted(Some(&empty), "explore"));
}
#[test]
fn agent_permitted_named_whitelist_matches_exact() {
let allowed = vec!["explore".to_string(), "coder".to_string()];
assert!(agent_permitted(Some(&allowed), "explore"));
assert!(agent_permitted(Some(&allowed), "coder"));
assert!(!agent_permitted(Some(&allowed), "oracle"));
assert!(!agent_permitted(Some(&allowed), "Explore"));
}
#[test] #[test]
fn handle_check_unknown_agent() { fn handle_check_unknown_agent() {
let mut ctx = ctx_with_supervisor(4, 3); let mut ctx = ctx_with_supervisor(4, 3);
@@ -1753,13 +1862,30 @@ mod tests {
} }
#[test] #[test]
fn dispatch_routes_list() { fn dispatch_routes_list_running() {
let mut ctx = ctx_with_supervisor(4, 3); let mut ctx = ctx_with_supervisor(4, 3);
let result = let result = run_async(handle_supervisor_tool(
run_async(handle_supervisor_tool(&mut ctx, "agent__list", &json!({}))).unwrap(); &mut ctx,
"agent__list_running",
&json!({}),
))
.unwrap();
assert!(result["active_count"].is_number()); assert!(result["active_count"].is_number());
} }
#[test]
fn dispatch_routes_list_available() {
let mut ctx = ctx_with_supervisor(4, 3);
let result = run_async(handle_supervisor_tool(
&mut ctx,
"agent__list_available",
&json!({}),
))
.unwrap();
assert!(result["count"].is_number());
assert!(result["agents"].is_array());
}
#[test] #[test]
fn dispatch_routes_task_list() { fn dispatch_routes_task_list() {
let mut ctx = ctx_with_supervisor(4, 3); let mut ctx = ctx_with_supervisor(4, 3);
+4
View File
@@ -329,6 +329,9 @@ fn build_inline_role(
if let Some(p) = node.top_p { if let Some(p) = node.top_p {
role.set_top_p(Some(p)); role.set_top_p(Some(p));
} }
if let Some(v) = &node.reasoning_effort {
role.set_reasoning_effort(Some(v.clone()));
}
if node.tools.as_deref().unwrap_or_default().is_empty() { if node.tools.as_deref().unwrap_or_default().is_empty() {
role.set_enabled_tools(Some(Vec::new())); role.set_enabled_tools(Some(Vec::new()));
@@ -499,6 +502,7 @@ mod tests {
model: None, model: None,
temperature: None, temperature: None,
top_p: None, top_p: None,
reasoning_effort: None,
fallback: None, fallback: None,
max_attempts: 1, max_attempts: 1,
max_iterations: 10, max_iterations: 10,
+25 -4
View File
@@ -33,7 +33,7 @@ async fn extract_via_extractor(
parent_ctx: &mut RequestContext, parent_ctx: &mut RequestContext,
is_repair: bool, is_repair: bool,
) -> Result<Value> { ) -> Result<Value> {
let role = build_extractor_role()?; let role = build_extractor_role(parent_ctx);
let prompt = build_extractor_prompt(raw, schema, is_repair); let prompt = build_extractor_prompt(raw, schema, is_repair);
let saved_role = parent_ctx.role.clone(); let saved_role = parent_ctx.role.clone();
@@ -53,11 +53,12 @@ async fn extract_via_extractor(
} }
} }
fn build_extractor_role() -> Result<Role> { fn build_extractor_role(ctx: &RequestContext) -> Role {
let mut role = Role::new(EXTRACTOR_ROLE_NAME, EXTRACTOR_ROLE_PROMPT); let mut role = Role::new(EXTRACTOR_ROLE_NAME, EXTRACTOR_ROLE_PROMPT);
role.set_model(ctx.current_model().clone());
role.set_enabled_tools(Some(Vec::new())); role.set_enabled_tools(Some(Vec::new()));
role.set_enabled_mcp_servers(Some(Vec::new())); role.set_enabled_mcp_servers(Some(Vec::new()));
Ok(role) role
} }
fn build_extractor_prompt(raw: &str, schema: &Value, is_repair: bool) -> String { fn build_extractor_prompt(raw: &str, schema: &Value, is_repair: bool) -> String {
@@ -107,8 +108,14 @@ fn strip_code_fences(s: &str) -> &str {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::client::Model;
use crate::config::{AppState, WorkingMode};
use serde_json::json; use serde_json::json;
fn make_ctx() -> RequestContext {
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
}
#[test] #[test]
fn try_parse_json_accepts_plain_object() { fn try_parse_json_accepts_plain_object() {
let v = try_parse_json(r#"{"a": 1}"#).unwrap(); let v = try_parse_json(r#"{"a": 1}"#).unwrap();
@@ -181,9 +188,23 @@ mod tests {
#[test] #[test]
fn build_extractor_role_disables_tools_and_mcp() { fn build_extractor_role_disables_tools_and_mcp() {
let role = build_extractor_role().expect("builtin role must exist"); let ctx = make_ctx();
let role = build_extractor_role(&ctx);
assert_eq!(role.enabled_tools().as_deref(), Some([].as_slice())); assert_eq!(role.enabled_tools().as_deref(), Some([].as_slice()));
assert_eq!(role.enabled_mcp_servers().as_deref(), Some([].as_slice())); assert_eq!(role.enabled_mcp_servers().as_deref(), Some([].as_slice()));
} }
#[test]
fn build_extractor_role_uses_parent_context_model() {
let mut ctx = make_ctx();
let mut parent_role = Role::new("parent", "parent prompt");
parent_role.set_model(Model::new("client-x", "model-y"));
ctx.role = Some(parent_role);
let role = build_extractor_role(&ctx);
assert_eq!(role.model().id(), "client-x:model-y");
}
} }
+6
View File
@@ -25,6 +25,9 @@ pub struct Graph {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>, pub top_p: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(default)] #[serde(default)]
pub global_tools: Vec<String>, pub global_tools: Vec<String>,
@@ -288,6 +291,9 @@ pub struct LlmNode {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>, pub top_p: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback: Option<String>, pub fallback: Option<String>,
+2
View File
@@ -946,6 +946,7 @@ mod tests {
model: None, model: None,
temperature: None, temperature: None,
top_p: None, top_p: None,
reasoning_effort: None,
global_tools: Vec::new(), global_tools: Vec::new(),
mcp_servers: Vec::new(), mcp_servers: Vec::new(),
skills_enabled: None, skills_enabled: None,
@@ -1048,6 +1049,7 @@ mod tests {
model: None, model: None,
temperature: None, temperature: None,
top_p: None, top_p: None,
reasoning_effort: None,
fallback: fallback.map(String::from), fallback: fallback.map(String::from),
max_attempts: 1, max_attempts: 1,
max_iterations: 10, max_iterations: 10,
+85 -30
View File
@@ -21,12 +21,13 @@ use crate::cli::Cli;
use crate::client::{ use crate::client::{
ModelType, call_chat_completions, call_chat_completions_streaming, list_models, oauth, ModelType, call_chat_completions, call_chat_completions_streaming, list_models, oauth,
}; };
use crate::config::paths; use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
use crate::config::{ use crate::config::{
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope, Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
install_builtins, list_agents, load_env_file, macro_execute, sync_models, 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::supervisor::{GuardrailAction, check_pending_agents_guardrail};
use crate::mcp::McpServersConfig; use crate::mcp::McpServersConfig;
use crate::render::{prompt_theme, render_error}; use crate::render::{prompt_theme, render_error};
@@ -187,7 +188,11 @@ async fn main() -> Result<()> {
let abort_signal = create_abort_signal(); let abort_signal = create_abort_signal();
let start_mcp_servers = cli.agent.is_none() && cli.role.is_none(); let start_mcp_servers = cli.agent.is_none() && cli.role.is_none();
let cfg = Config::load_with_interpolation(info_flag).await?; let cfg = Config::load_with_interpolation(info_flag).await?;
let app_config: Arc<AppConfig> = Arc::new(AppConfig::from_config(cfg)?); let mut app_config = AppConfig::from_config(cfg)?;
if cli.no_workspace_mcp {
app_config.no_workspace_mcp = true;
}
let app_config: Arc<AppConfig> = Arc::new(app_config);
let app_state: Arc<AppState> = Arc::new( let app_state: Arc<AppState> = Arc::new(
AppState::init( AppState::init(
app_config, app_config,
@@ -362,9 +367,21 @@ async fn run(
if cli.no_stream { if cli.no_stream {
update_app_config(&mut ctx, |app| app.stream = false); update_app_config(&mut ctx, |app| app.stream = false);
} }
if cli.raw_markdown {
update_app_config(&mut ctx, |app| app.raw_markdown = true);
}
if cli.no_memory { if cli.no_memory {
update_app_config(&mut ctx, |app| app.memory = Some(false)); update_app_config(&mut ctx, |app| app.memory = Some(false));
} }
if cli.no_workspace_instructions {
update_app_config(&mut ctx, |app| app.workspace_instructions = Some(false));
}
if !cli.workspace_instructions_file.is_empty() {
let files = cli.workspace_instructions_file.clone();
update_app_config(&mut ctx, |app| {
app.workspace_instructions_files = Some(files);
});
}
if cli.empty_session { if cli.empty_session {
ctx.empty_session()?; ctx.empty_session()?;
} }
@@ -374,13 +391,17 @@ async fn run(
if let Some(scope) = cli.init_memory { if let Some(scope) = cli.init_memory {
let (path, content) = match scope { let (path, content) = match scope {
MemoryScope::Global => ( MemoryScope::Global => (
paths::global_memory_index_path(), paths::global_memory_index_file(),
"# Global Memory\n\n<!-- Universal facts about you go here. The LLM uses this as always-on context. -->\n<!-- Drill files (when created) are listed below. -->\n", "# Global Memory\n\n<!-- Universal facts about you go here. The LLM uses this as always-on context. -->\n<!-- Drill files (when created) are listed below. -->\n",
), ),
MemoryScope::Workspace => ( MemoryScope::Workspace => {
env::current_dir()?.join("COYOTE.md"), let cwd = env::current_dir()?;
"# Workspace Memory\n\n<!-- Facts about this project go here. The LLM uses this as always-on context. -->\n", let root = memory::find_git_root(&cwd).unwrap_or(cwd);
), (
paths::workspace_memory_index_file_for(&root),
"# Workspace Memory Index\n\n<!-- Facts about this project go here. The LLM uses this as always-on context. -->\n<!-- Drill files (when created) are listed below. -->\n",
)
}
}; };
if path.exists() { if path.exists() {
@@ -393,9 +414,34 @@ async fn run(
} }
fs::write(&path, content)?; fs::write(&path, content)?;
if scope == MemoryScope::Workspace
&& let Some(git_root) = memory::find_git_root(&path)
{
memory::append_gitignore_entry(&git_root)?;
}
println!("✓ Created memory marker at '{}'.", path.display()); println!("✓ Created memory marker at '{}'.", path.display());
return Ok(()); return Ok(());
} }
if cli.init_instructions {
let path = env::current_dir()?.join(WORKSPACE_INSTRUCTIONS_FILE_NAME);
if path.exists() {
eprintln!(
"Workspace instructions already exist at '{}'.",
path.display()
);
return Ok(());
}
fs::write(
&path,
"# Project Instructions\n\n<!-- Human-curated instructions for AI agents working in this repo. -->\n<!-- Coyote injects this file into the system prompt read-only, in full. -->\n",
)?;
println!("✓ Created workspace instructions at '{}'.", path.display());
return Ok(());
}
if cli.info { if cli.info {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config); let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
let info = ctx.info(app.as_ref())?; let info = ctx.info(app.as_ref())?;
@@ -559,7 +605,7 @@ async fn shell_execute(
match answer_char { match answer_char {
'e' => { 'e' => {
debug!("{} {:?}", shell.cmd, &[&shell.arg, &eval_str]); debug!("{} {:?}", shell.cmd, [&shell.arg, &eval_str]);
let code = run_command(&shell.cmd, &[&shell.arg, &eval_str], None)?; let code = run_command(&shell.cmd, &[&shell.arg, &eval_str], None)?;
if code == 0 && app.save_shell_history { if code == 0 && app.save_shell_history {
let _ = append_to_shell_history(&shell.name, &eval_str, code); let _ = append_to_shell_history(&shell.name, &eval_str, code);
@@ -726,28 +772,37 @@ fn resolve_oauth_client(
explicit: Option<&str>, explicit: Option<&str>,
clients: &[ClientConfig], clients: &[ClientConfig],
) -> Result<(String, Box<dyn OAuthProvider>)> { ) -> Result<(String, Box<dyn OAuthProvider>)> {
if let Some(name) = explicit { let find_by_name = |name: &str| -> Option<&ClientConfig> {
let provider_type = oauth::resolve_provider_type(name, clients) clients.iter().find(|cc| {
.ok_or_else(|| anyhow!("Client '{name}' not found or doesn't support OAuth"))?; let (n, _, auth) = oauth::client_config_info(cc);
let provider = oauth::get_oauth_provider(provider_type).unwrap(); n == name && auth == Some("oauth")
return Ok((name.to_string(), provider)); })
} };
let candidates = oauth::list_oauth_capable_clients(clients); let target = if let Some(name) = explicit {
match candidates.len() { find_by_name(name)
0 => bail!("No OAuth-capable clients configured."), .ok_or_else(|| anyhow!("Client '{name}' not found or doesn't support OAuth"))?
1 => { } else {
let name = &candidates[0]; let candidates = oauth::list_oauth_capable_clients(clients);
let provider_type = oauth::resolve_provider_type(name, clients).unwrap(); match candidates.len() {
let provider = oauth::get_oauth_provider(provider_type).unwrap(); 0 => bail!("No OAuth-capable clients configured."),
Ok((name.clone(), provider)) 1 => find_by_name(&candidates[0]).unwrap(),
_ => {
let choice =
Select::new("Select a client to authenticate:", candidates.clone()).prompt()?;
find_by_name(&choice)
.ok_or_else(|| anyhow!("Selected client '{choice}' not found"))?
}
} }
_ => { };
let choice =
Select::new("Select a client to authenticate:", candidates.clone()).prompt()?; let name = oauth::client_config_info(target).0.to_string();
let provider_type = oauth::resolve_provider_type(&choice, clients).unwrap(); let provider = oauth::get_oauth_provider_for_client(target, &client::ALL_PROVIDER_MODELS)
let provider = oauth::get_oauth_provider(provider_type).unwrap(); .ok_or_else(|| {
Ok((choice, provider)) anyhow!(
} "Could not build OAuth provider for '{name}' (no oauth config in models.yaml or user config)"
} )
})?;
Ok((name, provider))
} }
+56 -1
View File
@@ -214,7 +214,52 @@ impl McpRegistry {
spec.validate(name)?; spec.validate(name)?;
} }
registry.config = Some(mcp_servers_config); let mut merged = mcp_servers_config;
if !app_config.no_workspace_mcp
&& let Some(ws_path) = paths::workspace_mcp_config_file()
{
match tokio::fs::read_to_string(&ws_path).await {
Ok(ws_content) if !ws_content.trim().is_empty() => {
match interpolate_secrets(&ws_content, vault) {
Ok((parsed, missing)) if missing.is_empty() => {
match serde_json::from_str::<McpServersConfig>(&parsed) {
Ok(ws_config) => {
let mut loaded = Vec::new();
for (name, spec) in ws_config.mcp_servers {
match spec.validate(&name) {
Ok(_) => {
loaded.push(name.clone());
merged.mcp_servers.insert(name, spec);
}
Err(e) => warn!(
"Invalid workspace MCP server '{name}': {e}. Skipping."
),
}
}
if !loaded.is_empty() {
eprintln!(
"Loading workspace MCP servers: {}",
loaded.join(", ")
);
}
}
Err(e) => {
warn!("Failed to parse workspace MCP config: {e}. Skipping.")
}
}
}
Ok((_, missing)) => warn!(
"Workspace MCP config references missing vault secrets: {missing:?}. Skipping."
),
Err(e) => {
warn!("Failed to process workspace MCP config: {e}. Skipping.")
}
}
}
_ => {}
}
}
registry.config = Some(merged);
if start_mcp_servers && app_config.mcp_server_support { if start_mcp_servers && app_config.mcp_server_support {
abortable_run_with_spinner( abortable_run_with_spinner(
@@ -1016,4 +1061,14 @@ mod tests {
assert!(!is_auth_required_error(&e)); assert!(!is_auth_required_error(&e));
} }
#[test]
fn is_auth_required_error_survives_context_wrapping() {
let e = anyhow!("Auth required, when send initialize request").context(
"MCP server 'github' requires OAuth authentication. \
Run `coyote --auth-mcp github` or `.mcp auth github` in the REPL to authenticate.",
);
assert!(is_auth_required_error(&e));
}
} }
+205 -32
View File
@@ -14,6 +14,8 @@ use url::Url;
struct ProtectedResourceMetadata { struct ProtectedResourceMetadata {
#[serde(default)] #[serde(default)]
authorization_servers: Vec<String>, authorization_servers: Vec<String>,
#[serde(default)]
scopes_supported: Vec<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -59,8 +61,8 @@ impl OAuthProvider for McpOAuthProvider {
"" ""
} }
fn scopes(&self) -> &str { fn scopes(&self) -> String {
&self.scopes self.scopes.clone()
} }
fn token_request_format(&self) -> TokenRequestFormat { fn token_request_format(&self) -> TokenRequestFormat {
@@ -140,7 +142,7 @@ fn mcp_token_key(server_name: &str) -> String {
} }
fn load_registered_client_id(server_name: &str) -> Option<String> { fn load_registered_client_id(server_name: &str) -> Option<String> {
let path = paths::oauth_tokens_path().join(format!("mcp_{server_name}_registration.json")); let path = paths::oauth_tokens_dir().join(format!("mcp_{server_name}_registration.json"));
let content = fs::read_to_string(path).ok()?; let content = fs::read_to_string(path).ok()?;
let reg: McpRegistration = serde_json::from_str(&content).ok()?; let reg: McpRegistration = serde_json::from_str(&content).ok()?;
@@ -148,7 +150,7 @@ fn load_registered_client_id(server_name: &str) -> Option<String> {
} }
fn save_registered_client_id(server_name: &str, client_id: &str) -> Result<()> { fn save_registered_client_id(server_name: &str, client_id: &str) -> Result<()> {
let dir = paths::oauth_tokens_path(); let dir = paths::oauth_tokens_dir();
fs::create_dir_all(&dir)?; fs::create_dir_all(&dir)?;
let path = dir.join(format!("mcp_{server_name}_registration.json")); let path = dir.join(format!("mcp_{server_name}_registration.json"));
@@ -187,46 +189,122 @@ async fn register_client(endpoint: &str, redirect_uri: &str) -> Result<String> {
} }
async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> { async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> {
let base = extract_base_url(server_url)?;
let client = Client::new(); let client = Client::new();
let mut tried: Vec<String> = Vec::new();
// RFC 9728: try protected resource metadata first; it points to the auth server // RFC 9728 @ 5.1: an unauthenticated request should yield a 401 whose
let pr_url = format!("{base}/.well-known/oauth-protected-resource"); // WWW-Authenticate challenge advertises the protected resource metadata URL.
if let Ok(resp) = client.get(&pr_url).send().await let mut pr_urls = Vec::new();
&& resp.status().is_success() if let Some(url) = probe_resource_metadata_url(&client, server_url).await {
&& let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await pr_urls.push(url);
&& let Some(auth_server) = pr.authorization_servers.first() }
{
let as_url = format!("{auth_server}/.well-known/oauth-authorization-server"); // RFC 9728 @ 3.1: path-aware well-known URL, then root as legacy fallback.
if let Ok(resp) = client.get(&as_url).send().await pr_urls.extend(well_known_urls(server_url, "oauth-protected-resource")?);
&& resp.status().is_success() pr_urls.dedup();
&& let Ok(meta) = resp.json::<OAuthServerMetadata>().await
{ for pr_url in &pr_urls {
return Ok(meta); tried.push(pr_url.clone());
let Ok(resp) = client.get(pr_url).send().await else {
continue;
};
if !resp.status().is_success() {
continue;
}
let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await else {
continue;
};
let Some(issuer) = pr.authorization_servers.first() else {
continue;
};
// RFC 8414 @ 3.1: for issuers with a path component the well-known
// segment is inserted BEFORE the path (with the legacy appended form
// and root as fallbacks).
for as_url in well_known_urls(issuer, "oauth-authorization-server")? {
tried.push(as_url.clone());
if let Ok(resp) = client.get(&as_url).send().await
&& resp.status().is_success()
&& let Ok(mut meta) = resp.json::<OAuthServerMetadata>().await
{
// Some auth servers (e.g. GitHub) omit scopes_supported from
// their metadata; fall back to the resource's advertised scopes.
if meta.scopes_supported.is_empty() {
meta.scopes_supported = pr.scopes_supported.clone();
}
return Ok(meta);
}
} }
} }
let as_url = format!("{base}/.well-known/oauth-authorization-server"); // Last resort: the MCP server itself may host authorization server metadata.
let resp = client for as_url in well_known_urls(server_url, "oauth-authorization-server")? {
.get(&as_url) tried.push(as_url.clone());
.send() if let Ok(resp) = client.get(&as_url).send().await
.await && resp.status().is_success()
.with_context(|| format!("Failed to reach {as_url}"))?; {
return resp
if resp.status().is_success() { .json::<OAuthServerMetadata>()
return resp .await
.json::<OAuthServerMetadata>() .with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
.await }
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
} }
Err(anyhow!( Err(anyhow!(
"Could not discover OAuth metadata for '{server_url}'.\n\ "Could not discover OAuth metadata for '{server_url}'.\n\
Tried:\n {pr_url}\n {as_url}\n\ Tried:\n {}\n\
Ensure the server supports MCP OAuth discovery, or consult its documentation." Ensure the server supports MCP OAuth discovery, or consult its documentation.",
tried.join("\n ")
)) ))
} }
/// Probes the MCP server with an unauthenticated request and extracts the
/// `resource_metadata` URL from the 401 `WWW-Authenticate` challenge (RFC 9728 @ 5.1).
async fn probe_resource_metadata_url(client: &Client, server_url: &str) -> Option<String> {
let resp = client.get(server_url).send().await.ok()?;
let header = resp.headers().get(reqwest::header::WWW_AUTHENTICATE)?;
parse_resource_metadata(header.to_str().ok()?)
}
/// Extracts the `resource_metadata` parameter value from a `WWW-Authenticate`
/// challenge, e.g. `Bearer error="...", resource_metadata="https://..."`.
fn parse_resource_metadata(challenge: &str) -> Option<String> {
let (_, rest) = challenge.split_once("resource_metadata=")?;
let rest = rest.trim_start();
let value = if let Some(stripped) = rest.strip_prefix('"') {
stripped.split('"').next()?
} else {
rest.split([',', ' ']).next()?
};
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
/// Builds candidate well-known metadata URLs for `url`, ordered by spec preference:
/// 1. Path-aware (RFC 8414 @ 3.1 / RFC 9728 @ 3.1): `{origin}/.well-known/{suffix}{path}`
/// 2. Legacy appended form: `{url}/.well-known/{suffix}`
/// 3. Root: `{origin}/.well-known/{suffix}`
///
/// URLs without a path component yield only the root form.
fn well_known_urls(url: &str, suffix: &str) -> Result<Vec<String>> {
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
let origin = extract_base_url(url)?;
let path = parsed.path().trim_end_matches('/');
let mut urls = Vec::new();
if !path.is_empty() && path != "/" {
urls.push(format!("{origin}/.well-known/{suffix}{path}"));
urls.push(format!("{origin}{path}/.well-known/{suffix}"));
}
urls.push(format!("{origin}/.well-known/{suffix}"));
Ok(urls)
}
fn extract_base_url(url: &str) -> Result<String> { fn extract_base_url(url: &str) -> Result<String> {
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?; let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
let scheme = parsed.scheme(); let scheme = parsed.scheme();
@@ -296,6 +374,101 @@ mod tests {
assert!(extract_base_url("not-a-url").is_err()); assert!(extract_base_url("not-a-url").is_err());
} }
#[test]
fn well_known_urls_path_aware_first_for_url_with_path() {
let urls = well_known_urls(
"https://api.githubcopilot.com/mcp",
"oauth-protected-resource",
)
.unwrap();
assert_eq!(
urls,
vec![
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp",
"https://api.githubcopilot.com/mcp/.well-known/oauth-protected-resource",
"https://api.githubcopilot.com/.well-known/oauth-protected-resource",
]
);
}
#[test]
fn well_known_urls_inserts_before_issuer_path() {
let urls = well_known_urls(
"https://github.com/login/oauth",
"oauth-authorization-server",
)
.unwrap();
assert_eq!(
urls[0],
"https://github.com/.well-known/oauth-authorization-server/login/oauth"
);
}
#[test]
fn well_known_urls_root_only_for_url_without_path() {
let urls = well_known_urls("https://mcp.notion.com", "oauth-authorization-server").unwrap();
assert_eq!(
urls,
vec!["https://mcp.notion.com/.well-known/oauth-authorization-server"]
);
}
#[test]
fn well_known_urls_ignores_trailing_slash() {
let urls = well_known_urls(
"https://api.githubcopilot.com/mcp/",
"oauth-protected-resource",
)
.unwrap();
assert_eq!(
urls[0],
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp"
);
}
#[test]
fn parse_resource_metadata_extracts_quoted_url() {
let challenge = r#"Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp""#;
let url = parse_resource_metadata(challenge);
assert_eq!(
url,
Some(
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp"
.to_string()
)
);
}
#[test]
fn parse_resource_metadata_extracts_unquoted_url() {
let challenge = "Bearer resource_metadata=https://example.com/.well-known/oauth-protected-resource/mcp, error=\"invalid_token\"";
let url = parse_resource_metadata(challenge);
assert_eq!(
url,
Some("https://example.com/.well-known/oauth-protected-resource/mcp".to_string())
);
}
#[test]
fn parse_resource_metadata_returns_none_when_absent() {
assert_eq!(
parse_resource_metadata(r#"Bearer error="invalid_token""#),
None
);
assert_eq!(
parse_resource_metadata(r#"Bearer resource_metadata="""#),
None
);
}
#[test] #[test]
#[serial] #[serial]
fn registered_client_id_roundtrip() { fn registered_client_id_roundtrip() {
+4 -5
View File
@@ -358,17 +358,16 @@ mod tests {
use super::*; use super::*;
use crate::function::JsonSchema; use crate::function::JsonSchema;
use std::fs; use std::fs;
use std::time::{SystemTime, UNIX_EPOCH}; use std::sync::atomic::{AtomicU64, Ordering};
static PARSE_COUNTER: AtomicU64 = AtomicU64::new(0);
fn parse_source( fn parse_source(
source: &str, source: &str,
file_name: &str, file_name: &str,
parent: &Path, parent: &Path,
) -> Result<Vec<FunctionDeclaration>> { ) -> Result<Vec<FunctionDeclaration>> {
let unique = SystemTime::now() let unique = PARSE_COUNTER.fetch_add(1, Ordering::Relaxed);
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_nanos();
let path = let path =
std::env::temp_dir().join(format!("coyote_python_parser_{file_name}_{unique}.py")); std::env::temp_dir().join(format!("coyote_python_parser_{file_name}_{unique}.py"));
fs::write(&path, source).expect("failed to write temp python source"); fs::write(&path, source).expect("failed to write temp python source");
+643 -21
View File
@@ -2,12 +2,21 @@ use super::DocumentId;
use crate::client::*; use crate::client::*;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use indexmap::IndexMap; use indexmap::{IndexMap, IndexSet};
use petgraph::Direction; use petgraph::Direction;
use petgraph::graph::NodeIndex; use petgraph::graph::NodeIndex;
use petgraph::stable_graph::StableGraph; use petgraph::stable_graph::StableGraph;
use petgraph::visit::EdgeRef;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
/// Heuristic upper bound on chunk size before warning the user that the
/// extraction LLM call may be truncated. Not a hard limit.
const MAX_CHUNK_CHARS: usize = 24_000;
/// Maximum number of nodes the BFS may visit during a single graph_search.
/// Keeps the synchronous traversal bounded on dense graphs.
pub const MAX_GRAPH_NODES: usize = 500;
const EXTRACTION_PROMPT: &str = r#"Extract entities and relationships from the following text chunk. const EXTRACTION_PROMPT: &str = r#"Extract entities and relationships from the following text chunk.
@@ -89,16 +98,27 @@ impl Default for KnowledgeGraph {
impl KnowledgeGraph { impl KnowledgeGraph {
pub fn merge(&mut self, doc_id: DocumentId, result: ExtractionResult) { pub fn merge(&mut self, doc_id: DocumentId, result: ExtractionResult) {
let mut chunk_nodes: Vec<u32> = vec![]; let mut chunk_nodes: IndexSet<u32> = IndexSet::new();
for extracted in &result.entities { for extracted in &result.entities {
let key = extracted.name.to_lowercase(); let key = extracted.name.to_lowercase();
let normalized_type = extracted.entity_type.to_uppercase();
let node_raw = if let Some(&existing) = self.entity_index.get(&key) { let node_raw = if let Some(&existing) = self.entity_index.get(&key) {
let idx = NodeIndex::new(existing as usize);
if self.graph.contains_node(idx) {
let node = &mut self.graph[idx];
if node.entity_type == "OTHER" && normalized_type != "OTHER" {
node.entity_type = normalized_type;
}
if node.description.is_none() {
node.description = extracted.description.clone();
}
}
existing existing
} else { } else {
let entity = Entity { let entity = Entity {
name: extracted.name.clone(), name: extracted.name.clone(),
entity_type: extracted.entity_type.clone(), entity_type: normalized_type,
description: extracted.description.clone(), description: extracted.description.clone(),
}; };
let idx = self.graph.add_node(entity); let idx = self.graph.add_node(entity);
@@ -106,7 +126,7 @@ impl KnowledgeGraph {
self.entity_index.insert(key, raw); self.entity_index.insert(key, raw);
raw raw
}; };
chunk_nodes.push(node_raw); chunk_nodes.insert(node_raw);
} }
for extracted in &result.relationships { for extracted in &result.relationships {
@@ -118,11 +138,14 @@ impl KnowledgeGraph {
) { ) {
let from_idx = NodeIndex::new(from_raw as usize); let from_idx = NodeIndex::new(from_raw as usize);
let to_idx = NodeIndex::new(to_raw as usize); let to_idx = NodeIndex::new(to_raw as usize);
// Avoid duplicate edges let already_exists = self
if !self.graph.contains_edge(from_idx, to_idx) { .graph
.edges_connecting(from_idx, to_idx)
.any(|e| e.weight().relation_type == extracted.relation_type);
if !already_exists {
let rel = Relationship { let rel = Relationship {
relation_type: extracted.relation_type.clone(), relation_type: extracted.relation_type.clone(),
weight: extracted.weight.unwrap_or(1.0), weight: extracted.weight.unwrap_or(1.0).clamp(0.0, 1.0),
}; };
self.graph.add_edge(from_idx, to_idx, rel); self.graph.add_edge(from_idx, to_idx, rel);
} }
@@ -158,6 +181,10 @@ impl KnowledgeGraph {
.filter(|raw| !still_used.contains(raw)) .filter(|raw| !still_used.contains(raw))
.collect(); .collect();
if to_remove.is_empty() {
return;
}
for raw in to_remove { for raw in to_remove {
let idx = NodeIndex::new(raw as usize); let idx = NodeIndex::new(raw as usize);
if self.graph.contains_node(idx) { if self.graph.contains_node(idx) {
@@ -166,6 +193,57 @@ impl KnowledgeGraph {
self.entity_index.swap_remove(&name); self.entity_index.swap_remove(&name);
} }
} }
self.compact();
}
/// Rebuild the internal graph with consecutive node indices. Eliminates
/// the null tombstone slots that petgraph's StableGraph accumulates after
/// repeated `remove_node` calls, keeping serialized YAML size in check.
fn compact(&mut self) {
let mut new_graph: StableGraph<Entity, Relationship> = StableGraph::new();
let mut old_to_new: HashMap<u32, u32> = HashMap::new();
for &old_raw in self.entity_index.values() {
let old_idx = NodeIndex::new(old_raw as usize);
if self.graph.contains_node(old_idx) {
let entity = self.graph[old_idx].clone();
let new_idx = new_graph.add_node(entity);
old_to_new.insert(old_raw, new_idx.index() as u32);
}
}
for edge_idx in self.graph.edge_indices() {
if let Some((from, to)) = self.graph.edge_endpoints(edge_idx) {
let from_raw = from.index() as u32;
let to_raw = to.index() as u32;
if let (Some(&new_from), Some(&new_to)) =
(old_to_new.get(&from_raw), old_to_new.get(&to_raw))
{
let rel = self.graph[edge_idx].clone();
new_graph.add_edge(
NodeIndex::new(new_from as usize),
NodeIndex::new(new_to as usize),
rel,
);
}
}
}
for raw in self.entity_index.values_mut() {
if let Some(&new_raw) = old_to_new.get(raw) {
*raw = new_raw;
}
}
for node_raws in self.document_entities.values_mut() {
*node_raws = node_raws
.iter()
.filter_map(|raw| old_to_new.get(raw).copied())
.collect();
}
self.graph = new_graph;
} }
pub fn build_node_to_docs(&self) -> IndexMap<u32, Vec<DocumentId>> { pub fn build_node_to_docs(&self) -> IndexMap<u32, Vec<DocumentId>> {
@@ -179,30 +257,79 @@ impl KnowledgeGraph {
map map
} }
pub fn expand_neighbors(&self, seed_nodes: &[u32], hops: usize) -> Vec<u32> { /// BFS from seed nodes with weight-decayed scoring.
let mut expanded: indexmap::IndexSet<u32> = seed_nodes.iter().copied().collect(); ///
let mut frontier: Vec<u32> = seed_nodes.to_vec(); /// Seed node scores are provided by the caller (typically token-overlap
/// ratios). Each neighbor's score is `edge_weight * parent_score`, so
/// strongly-connected neighbors rank higher and weakly-connected ones
/// naturally contribute less. Traversal is capped at `MAX_GRAPH_NODES`
/// total nodes; the highest-scored frontier nodes are expanded first so
/// the budget is spent on the most relevant entities.
///
/// Returns a map of raw node index → score (includes seed nodes).
pub fn expand_neighbors_scored(
&self,
seed_scores: &[(u32, f32)],
hops: usize,
) -> IndexMap<u32, f32> {
let mut node_scores: IndexMap<u32, f32> = IndexMap::new();
for &(raw, score) in seed_scores {
node_scores.insert(raw, score);
}
let mut frontier: Vec<(u32, f32)> = seed_scores.to_vec();
for _ in 0..hops { for _ in 0..hops {
let mut next_frontier: Vec<u32> = vec![]; if node_scores.len() >= MAX_GRAPH_NODES {
for &raw in &frontier { break;
let idx = NodeIndex::new(raw as usize); }
if self.graph.contains_node(idx) {
for dir in [Direction::Outgoing, Direction::Incoming] { frontier.sort_unstable_by(|a, b| {
for neighbor in self.graph.neighbors_directed(idx, dir) { b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
let n = neighbor.index() as u32; });
if expanded.insert(n) {
next_frontier.push(n); let mut next_frontier: Vec<(u32, f32)> = vec![];
'nodes: for (raw, parent_score) in &frontier {
let idx = NodeIndex::new(*raw as usize);
if !self.graph.contains_node(idx) {
continue;
}
for dir in [Direction::Outgoing, Direction::Incoming] {
for edge_ref in self.graph.edges_directed(idx, dir) {
let neighbor_idx = match dir {
Direction::Outgoing => edge_ref.target(),
Direction::Incoming => edge_ref.source(),
};
let neighbor_raw = neighbor_idx.index() as u32;
let candidate = edge_ref.weight().weight * parent_score;
match node_scores.entry(neighbor_raw) {
indexmap::map::Entry::Vacant(e) => {
e.insert(candidate);
next_frontier.push((neighbor_raw, candidate));
} }
indexmap::map::Entry::Occupied(mut e) => {
if candidate > *e.get() {
*e.get_mut() = candidate;
}
}
}
if node_scores.len() >= MAX_GRAPH_NODES {
break 'nodes;
} }
} }
} }
} }
frontier = next_frontier; frontier = next_frontier;
if frontier.is_empty() { if frontier.is_empty() {
break; break;
} }
} }
expanded.into_iter().collect()
node_scores
} }
} }
@@ -213,6 +340,14 @@ pub async fn extract_entities(
chunk: &str, chunk: &str,
prompt_template: Option<&str>, prompt_template: Option<&str>,
) -> Result<ExtractionResult> { ) -> Result<ExtractionResult> {
if chunk.len() > MAX_CHUNK_CHARS {
warn!(
"Entity extraction chunk is {} chars (heuristic limit: {}); \
the LLM response may be truncated",
chunk.len(),
MAX_CHUNK_CHARS
);
}
let template = prompt_template.unwrap_or(EXTRACTION_PROMPT); let template = prompt_template.unwrap_or(EXTRACTION_PROMPT);
let prompt = template.replace("__CHUNK__", chunk); let prompt = template.replace("__CHUNK__", chunk);
let mut messages = vec![Message::new( let mut messages = vec![Message::new(
@@ -227,6 +362,7 @@ pub async fn extract_entities(
messages, messages,
temperature: Some(0.0), temperature: Some(0.0),
top_p: None, top_p: None,
reasoning_effort: None,
functions: None, functions: None,
stream: false, stream: false,
}; };
@@ -250,3 +386,489 @@ pub async fn extract_entities(
serde_json::from_str::<ExtractionResult>(&json) serde_json::from_str::<ExtractionResult>(&json)
.context("Failed to parse entity extraction JSON") .context("Failed to parse entity extraction JSON")
} }
#[cfg(test)]
mod tests {
use super::*;
fn entity(name: &str, entity_type: &str) -> ExtractedEntity {
ExtractedEntity {
name: name.to_string(),
entity_type: entity_type.to_string(),
description: None,
}
}
fn rel(from: &str, to: &str, rel_type: &str, weight: f32) -> ExtractedRelationship {
ExtractedRelationship {
from: from.to_string(),
to: to.to_string(),
relation_type: rel_type.to_string(),
weight: Some(weight),
}
}
fn doc(id: usize) -> DocumentId {
DocumentId(id)
}
fn extraction(
entities: Vec<ExtractedEntity>,
rels: Vec<ExtractedRelationship>,
) -> ExtractionResult {
ExtractionResult {
entities,
relationships: rels,
}
}
#[test]
fn merge_deduplicates_by_lowercase_name() {
let mut kg = KnowledgeGraph::default();
kg.merge(
doc(0),
extraction(
vec![
entity("Python", "TECHNOLOGY"),
entity("python", "TECHNOLOGY"),
],
vec![],
),
);
assert_eq!(kg.entity_index.len(), 1);
assert_eq!(kg.graph.node_count(), 1);
}
#[test]
fn merge_chunk_nodes_no_duplicate_doc_entries() {
let mut kg = KnowledgeGraph::default();
kg.merge(
doc(1),
extraction(
vec![
entity("Python", "TECHNOLOGY"),
entity("python", "TECHNOLOGY"),
],
vec![],
),
);
let count = kg.document_entities.get(&1).map(|v| v.len()).unwrap_or(0);
assert_eq!(
count, 1,
"duplicate entity in one chunk should produce one doc_entity entry"
);
}
#[test]
fn merge_normalizes_entity_type_to_uppercase() {
let mut kg = KnowledgeGraph::default();
kg.merge(
doc(0),
extraction(vec![entity("Django", "technology")], vec![]),
);
let raw = kg.entity_index["django"];
assert_eq!(
kg.graph[NodeIndex::new(raw as usize)].entity_type,
"TECHNOLOGY"
);
}
#[test]
fn merge_promotes_type_from_other_to_specific() {
let mut kg = KnowledgeGraph::default();
kg.merge(doc(0), extraction(vec![entity("Python", "OTHER")], vec![]));
kg.merge(
doc(1),
extraction(vec![entity("Python", "TECHNOLOGY")], vec![]),
);
let raw = kg.entity_index["python"];
assert_eq!(
kg.graph[NodeIndex::new(raw as usize)].entity_type,
"TECHNOLOGY"
);
}
#[test]
fn merge_does_not_demote_specific_type_to_other() {
let mut kg = KnowledgeGraph::default();
kg.merge(
doc(0),
extraction(vec![entity("Python", "TECHNOLOGY")], vec![]),
);
kg.merge(doc(1), extraction(vec![entity("Python", "OTHER")], vec![]));
let raw = kg.entity_index["python"];
assert_eq!(
kg.graph[NodeIndex::new(raw as usize)].entity_type,
"TECHNOLOGY"
);
}
#[test]
fn merge_allows_multiple_relation_types_between_same_pair() {
let mut kg = KnowledgeGraph::default();
kg.merge(
doc(0),
extraction(
vec![
entity("Python", "TECHNOLOGY"),
entity("Django", "TECHNOLOGY"),
],
vec![rel("Python", "Django", "implements", 0.9)],
),
);
kg.merge(
doc(1),
extraction(
vec![
entity("Python", "TECHNOLOGY"),
entity("Django", "TECHNOLOGY"),
],
vec![rel("Python", "Django", "uses", 0.8)],
),
);
let from_idx = NodeIndex::new(kg.entity_index["python"] as usize);
let to_idx = NodeIndex::new(kg.entity_index["django"] as usize);
let count = kg.graph.edges_connecting(from_idx, to_idx).count();
assert_eq!(
count, 2,
"two different relation types should produce two edges"
);
}
#[test]
fn merge_deduplicates_same_relation_type() {
let mut kg = KnowledgeGraph::default();
kg.merge(
doc(0),
extraction(
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
vec![rel("A", "B", "uses", 1.0)],
),
);
kg.merge(
doc(1),
extraction(
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
vec![rel("A", "B", "uses", 0.5)],
),
);
let from_idx = NodeIndex::new(kg.entity_index["a"] as usize);
let to_idx = NodeIndex::new(kg.entity_index["b"] as usize);
let count = kg.graph.edges_connecting(from_idx, to_idx).count();
assert_eq!(
count, 1,
"same relation type should not create a duplicate edge"
);
}
#[test]
fn remove_documents_preserves_entity_shared_across_docs() {
let mut kg = KnowledgeGraph::default();
kg.merge(
doc(0),
extraction(
vec![entity("Python", "TECHNOLOGY"), entity("A", "CONCEPT")],
vec![],
),
);
kg.merge(
doc(1),
extraction(
vec![entity("Python", "TECHNOLOGY"), entity("B", "CONCEPT")],
vec![],
),
);
kg.remove_documents(&[doc(0)]);
assert!(
kg.entity_index.contains_key("python"),
"shared entity should survive"
);
assert!(
!kg.entity_index.contains_key("a"),
"exclusive entity should be removed"
);
assert!(
kg.entity_index.contains_key("b"),
"other doc's entity should survive"
);
}
#[test]
fn remove_documents_noop_on_empty_slice() {
let mut kg = KnowledgeGraph::default();
kg.merge(doc(0), extraction(vec![entity("X", "CONCEPT")], vec![]));
kg.remove_documents(&[]);
assert_eq!(kg.entity_index.len(), 1);
}
#[test]
fn remove_documents_compacts_graph() {
let mut kg = KnowledgeGraph::default();
// doc 0: A, B with an edge
kg.merge(
doc(0),
extraction(
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
vec![rel("A", "B", "uses", 1.0)],
),
);
// doc 1: C only
kg.merge(doc(1), extraction(vec![entity("C", "CONCEPT")], vec![]));
kg.remove_documents(&[doc(0)]);
assert_eq!(kg.graph.node_count(), 1);
let c_raw = kg.entity_index["c"];
assert_eq!(
c_raw, 0,
"compacted graph should give surviving node index 0"
);
let refs = kg.document_entities.get(&1).cloned().unwrap_or_default();
assert_eq!(refs, vec![0u32]);
}
#[test]
fn expand_zero_hops_returns_seeds_only() {
let mut kg = KnowledgeGraph::default();
kg.merge(
doc(0),
extraction(
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
vec![rel("A", "B", "uses", 0.9)],
),
);
let a_raw = kg.entity_index["a"];
let result = kg.expand_neighbors_scored(&[(a_raw, 1.0)], 0);
assert_eq!(result.len(), 1);
assert_eq!(result[&a_raw], 1.0);
}
#[test]
fn expand_one_hop_decays_score_by_edge_weight() {
let mut kg = KnowledgeGraph::default();
kg.merge(
doc(0),
extraction(
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
vec![rel("A", "B", "uses", 0.8)],
),
);
let a_raw = kg.entity_index["a"];
let b_raw = kg.entity_index["b"];
let result = kg.expand_neighbors_scored(&[(a_raw, 1.0)], 1);
assert_eq!(result.len(), 2);
assert_eq!(result[&a_raw], 1.0);
let b_score = result[&b_raw];
assert!(
(b_score - 0.8).abs() < 1e-6,
"neighbor score should be edge_weight * parent_score = 0.8, got {b_score}"
);
}
#[test]
fn expand_incoming_edges_also_traversed() {
let mut kg = KnowledgeGraph::default();
// Edge goes B → A; seeding A should still discover B via incoming edge
kg.merge(
doc(0),
extraction(
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
vec![rel("B", "A", "uses", 0.7)],
),
);
let a_raw = kg.entity_index["a"];
let b_raw = kg.entity_index["b"];
let result = kg.expand_neighbors_scored(&[(a_raw, 1.0)], 1);
assert!(
result.contains_key(&b_raw),
"B should be reachable via incoming edge from A"
);
let b_score = result[&b_raw];
assert!((b_score - 0.7).abs() < 1e-6);
}
#[test]
fn expand_picks_best_path_score() {
let mut kg = KnowledgeGraph::default();
// A(0.5) → C(0.9): score 0.45; B(1.0) → C(0.4): score 0.40 — A→C path wins.
kg.merge(
doc(0),
extraction(
vec![
entity("A", "CONCEPT"),
entity("B", "CONCEPT"),
entity("C", "CONCEPT"),
],
vec![rel("A", "C", "uses", 0.9), rel("B", "C", "uses", 0.4)],
),
);
let a_raw = kg.entity_index["a"];
let b_raw = kg.entity_index["b"];
let c_raw = kg.entity_index["c"];
let seeds = vec![(a_raw, 0.5f32), (b_raw, 1.0f32)];
let result = kg.expand_neighbors_scored(&seeds, 1);
let c_score = result[&c_raw];
// Best path: B(1.0) * 0.4 = 0.4, A(0.5) * 0.9 = 0.45 → should be 0.45
assert!(
(c_score - 0.45).abs() < 1e-6,
"C score should reflect best path (0.45), got {c_score}"
);
}
#[test]
fn build_node_to_docs_maps_shared_entity_to_multiple_docs() {
let mut kg = KnowledgeGraph::default();
kg.merge(
doc(0),
extraction(vec![entity("Python", "TECHNOLOGY")], vec![]),
);
kg.merge(
doc(1),
extraction(vec![entity("Python", "TECHNOLOGY")], vec![]),
);
let n2d = kg.build_node_to_docs();
let raw = kg.entity_index["python"];
let docs = &n2d[&raw];
assert!(docs.contains(&DocumentId(0)));
assert!(docs.contains(&DocumentId(1)));
}
#[test]
fn compact_preserves_edges_between_survivors() {
let mut kg = KnowledgeGraph::default();
kg.merge(doc(0), extraction(vec![entity("A", "CONCEPT")], vec![]));
kg.merge(
doc(1),
extraction(
vec![entity("B", "CONCEPT"), entity("C", "CONCEPT")],
vec![rel("B", "C", "linked", 0.8)],
),
);
kg.remove_documents(&[doc(0)]);
let b_raw = kg.entity_index["b"];
let c_raw = kg.entity_index["c"];
let b_idx = NodeIndex::new(b_raw as usize);
let c_idx = NodeIndex::new(c_raw as usize);
assert_eq!(
kg.graph.edges_connecting(b_idx, c_idx).count(),
1,
"B→C edge should survive compaction"
);
}
#[test]
fn expand_two_hops_reaches_transitive_neighbor() {
let mut kg = KnowledgeGraph::default();
kg.merge(
doc(0),
extraction(
vec![
entity("A", "CONCEPT"),
entity("B", "CONCEPT"),
entity("C", "CONCEPT"),
],
vec![rel("A", "B", "uses", 1.0), rel("B", "C", "uses", 0.5)],
),
);
let a_raw = kg.entity_index["a"];
let c_raw = kg.entity_index["c"];
let one_hop = kg.expand_neighbors_scored(&[(a_raw, 1.0)], 1);
assert!(
!one_hop.contains_key(&c_raw),
"C should not be reachable at 1 hop"
);
let two_hop = kg.expand_neighbors_scored(&[(a_raw, 1.0)], 2);
assert!(
two_hop.contains_key(&c_raw),
"C should be reachable at 2 hops"
);
let c_score = two_hop[&c_raw];
assert!(
(c_score - 0.5).abs() < 1e-6,
"C score should be 1.0 * 1.0 * 0.5 = 0.5, got {c_score}"
);
}
#[test]
fn merge_clamps_edge_weight_above_one() {
let mut kg = KnowledgeGraph::default();
kg.merge(
doc(0),
extraction(
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
vec![rel("A", "B", "uses", 1.5)],
),
);
let a_raw = kg.entity_index["a"];
let b_raw = kg.entity_index["b"];
let result = kg.expand_neighbors_scored(&[(a_raw, 1.0)], 1);
let b_score = result[&b_raw];
assert!(
(b_score - 1.0).abs() < 1e-6,
"weight 1.5 clamped to 1.0: b_score should be 1.0, got {b_score}"
);
}
#[test]
fn merge_clamps_edge_weight_below_zero() {
let mut kg = KnowledgeGraph::default();
kg.merge(
doc(0),
extraction(
vec![entity("A", "CONCEPT"), entity("B", "CONCEPT")],
vec![rel("A", "B", "uses", -0.5)],
),
);
let a_raw = kg.entity_index["a"];
let b_raw = kg.entity_index["b"];
let result = kg.expand_neighbors_scored(&[(a_raw, 1.0)], 1);
let b_score = result.get(&b_raw).copied().unwrap_or(0.0);
assert!(
b_score.abs() < 1e-6,
"weight -0.5 clamped to 0.0: b_score should be 0.0, got {b_score}"
);
}
#[test]
fn merge_fills_missing_description_from_later_chunk() {
let mut kg = KnowledgeGraph::default();
kg.merge(
doc(0),
extraction(vec![entity("Python", "TECHNOLOGY")], vec![]),
);
kg.merge(
doc(1),
ExtractionResult {
entities: vec![ExtractedEntity {
name: "python".to_string(),
entity_type: "TECHNOLOGY".to_string(),
description: Some("A general-purpose language".to_string()),
}],
relationships: vec![],
},
);
let raw = kg.entity_index["python"];
let desc = &kg.graph[NodeIndex::new(raw as usize)].description;
assert_eq!(
desc.as_deref(),
Some("A general-purpose language"),
"description should be backfilled from later chunk"
);
}
#[test]
fn remove_all_documents_empties_graph() {
let mut kg = KnowledgeGraph::default();
kg.merge(doc(0), extraction(vec![entity("A", "CONCEPT")], vec![]));
kg.merge(doc(1), extraction(vec![entity("B", "CONCEPT")], vec![]));
kg.remove_documents(&[doc(0), doc(1)]);
assert_eq!(kg.graph.node_count(), 0, "all nodes should be removed");
assert_eq!(kg.entity_index.len(), 0, "entity index should be empty");
assert!(
kg.document_entities.is_empty(),
"document_entities should be empty"
);
}
}
+138 -41
View File
@@ -25,6 +25,8 @@ use std::{
}; };
use tokio::time::sleep; use tokio::time::sleep;
const BM25_SEED_SCORE: f32 = 0.5;
const RAG_TEMPLATE: &str = r#"Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags) const RAG_TEMPLATE: &str = r#"Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags)
<context> <context>
@@ -752,14 +754,14 @@ impl Rag {
bail!("No RAG files"); bail!("No RAG files");
} }
if self.data.extractor_model.is_some() if !new_doc_contents.is_empty()
&& !new_doc_contents.is_empty()
&& let Some(extractor_model_id) = self.data.extractor_model.clone() && let Some(extractor_model_id) = self.data.extractor_model.clone()
{ {
match Model::retrieve_model(&self.app_config, &extractor_model_id, ModelType::Chat) { match Model::retrieve_model(&self.app_config, &extractor_model_id, ModelType::Chat) {
Ok(model) => match self.create_embeddings_client(model) { Ok(model) => match self.create_embeddings_client(model) {
Ok(client) => { Ok(client) => {
let total = new_doc_contents.len(); let total = new_doc_contents.len();
let mut failures = 0usize;
for (i, (doc_id, content)) in new_doc_contents.into_iter().enumerate() { for (i, (doc_id, content)) in new_doc_contents.into_iter().enumerate() {
progress( progress(
&spinner, &spinner,
@@ -774,14 +776,21 @@ impl Rag {
{ {
Ok(result) => self.data.knowledge_graph.merge(doc_id, result), Ok(result) => self.data.knowledge_graph.merge(doc_id, result),
Err(e) => { Err(e) => {
debug!("Entity extraction failed for doc {doc_id:?}: {e}") warn!("Entity extraction failed for doc {doc_id:?}: {e}");
failures += 1;
} }
} }
} }
if failures > 0 {
progress(
&spinner,
format!("Entity extraction: {failures}/{total} chunks failed"),
);
}
} }
Err(e) => debug!("Failed to create extractor client: {e}"), Err(e) => warn!("Failed to create extractor client: {e}"),
}, },
Err(e) => debug!("Extractor model not found: {e}"), Err(e) => warn!("Extractor model not found: {e}"),
} }
} }
@@ -930,9 +939,31 @@ impl Rag {
if kg.entity_index.is_empty() { if kg.entity_index.is_empty() {
return vec![]; return vec![];
} }
let query_lower = query.to_lowercase();
let mut seed_nodes: Vec<u32> = kg let query_lower = query.to_lowercase();
let query_tokens: Vec<&str> = query_lower.split_whitespace().collect();
let token_count = query_tokens.len().max(1);
let score_node = |raw: u32| -> f32 {
let idx = NodeIndex::new(raw as usize);
if !kg.graph.contains_node(idx) {
return 0.0;
}
let entity = &kg.graph[idx];
let combined = format!(
"{} {}",
entity.name,
entity.description.as_deref().unwrap_or("")
)
.to_lowercase();
query_tokens
.iter()
.filter(|t| combined.contains(*t))
.count() as f32
/ token_count as f32
};
let mut seed_scores: Vec<(u32, f32)> = kg
.entity_index .entity_index
.iter() .iter()
.filter(|(name, _)| { .filter(|(name, _)| {
@@ -946,52 +977,31 @@ impl Rag {
.any(|token| token.trim_matches(|c: char| !c.is_alphanumeric()) == name_str) .any(|token| token.trim_matches(|c: char| !c.is_alphanumeric()) == name_str)
} }
}) })
.map(|(_, &raw)| raw) .map(|(_, &raw)| (raw, score_node(raw).max(BM25_SEED_SCORE)))
.collect(); .collect();
if seed_nodes.is_empty() { if seed_scores.is_empty() {
let bm25_results = self.bm25.search(query, top_k * 2); let bm25_results = self.bm25.search(query, top_k * 2);
'outer: for result in bm25_results { 'outer: for result in bm25_results {
if let Some(node_raws) = kg.document_entities.get(&result.document.id.0) { if let Some(node_raws) = kg.document_entities.get(&result.document.id.0) {
seed_nodes.extend(node_raws.iter().copied()); for &raw in node_raws {
if seed_nodes.len() >= top_k { seed_scores.push((raw, BM25_SEED_SCORE));
break 'outer; if seed_scores.len() >= top_k {
break 'outer;
}
} }
} }
} }
} }
if seed_nodes.is_empty() { if seed_scores.is_empty() {
return vec![]; return vec![];
} }
let hops = self.data.graph_hops.unwrap_or(1); let hops = self.data.graph_hops.unwrap_or(1);
let expanded = kg.expand_neighbors(&seed_nodes, hops); let mut scored: Vec<(u32, f32)> = kg
.expand_neighbors_scored(&seed_scores, hops)
let query_tokens: Vec<&str> = query_lower.split_whitespace().collect();
let token_count = query_tokens.len().max(1);
let mut scored: Vec<(u32, f32)> = expanded
.into_iter() .into_iter()
.map(|raw| {
let idx = NodeIndex::new(raw as usize);
let score = if kg.graph.contains_node(idx) {
let entity = &kg.graph[idx];
let combined = format!(
"{} {}",
entity.name,
entity.description.as_deref().unwrap_or("")
)
.to_lowercase();
query_tokens
.iter()
.filter(|t| combined.contains(*t))
.count() as f32
/ token_count as f32
} else {
0.0
};
(raw, score)
})
.collect(); .collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal)); scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
@@ -1349,11 +1359,11 @@ fn set_chunk_size(model: &Model) -> Result<usize> {
fn set_graph_hops(default_value: usize) -> Result<usize> { fn set_graph_hops(default_value: usize) -> Result<usize> {
let value = Text::new("Set graph expansion hops:") let value = Text::new("Set graph expansion hops:")
.with_default(&default_value.to_string()) .with_default(&default_value.to_string())
.with_help_message("Number of hops to expand from matched entities (1 = direct neighbors, 2 = neighbors of neighbors)") .with_help_message("Number of hops to expand from matched entities (0 = seed nodes only, 1 = direct neighbors, 2 = neighbors of neighbors)")
.with_validator(move |text: &str| { .with_validator(move |text: &str| {
let out = match text.parse::<usize>() { let out = match text.parse::<usize>() {
Ok(v) if v >= 1 => Validation::Valid, Ok(_) => Validation::Valid,
_ => Validation::Invalid("Must be an integer >= 1".into()), _ => Validation::Invalid("Must be a non-negative integer".into()),
}; };
Ok(out) Ok(out)
}) })
@@ -1771,4 +1781,91 @@ mod tests {
assert_eq!(file_idx, 0); assert_eq!(file_idx, 0);
assert_eq!(doc_idx, 0); assert_eq!(doc_idx, 0);
} }
#[test]
fn rag_data_del_removes_graph_entities() {
use super::graph::{ExtractedEntity, ExtractionResult};
let mut data = RagData::new(
"m".into(),
100,
10,
None,
5,
None,
GraphRagConfig::default(),
);
let file = RagFile {
hash: "abc".into(),
path: "test.txt".into(),
documents: vec![RagDocument::new("Python is great")],
};
data.files.insert(0, file);
let doc_id = DocumentId::new(0, 0);
data.knowledge_graph.merge(
doc_id,
ExtractionResult {
entities: vec![ExtractedEntity {
name: "Python".to_string(),
entity_type: "TECHNOLOGY".to_string(),
description: None,
}],
relationships: vec![],
},
);
assert!(
data.knowledge_graph.entity_index.contains_key("python"),
"entity should exist before del"
);
data.del(vec![0]);
assert!(
!data.knowledge_graph.entity_index.contains_key("python"),
"entity should be removed after del"
);
}
#[test]
fn reciprocal_rank_fusion_empty_lists() {
let result = super::reciprocal_rank_fusion(vec![], vec![], 5);
assert!(result.is_empty(), "empty input should produce empty output");
}
#[test]
fn reciprocal_rank_fusion_deduplicates_across_signals() {
let doc_a = DocumentId::new(0, 0);
let doc_b = DocumentId::new(0, 1);
let result = super::reciprocal_rank_fusion(
vec![vec![doc_a, doc_b], vec![doc_a, doc_b]],
vec![1.0, 1.0],
5,
);
let unique: std::collections::HashSet<_> = result.iter().collect();
assert_eq!(
unique.len(),
result.len(),
"each document should appear at most once"
);
assert_eq!(result.len(), 2);
}
#[test]
fn reciprocal_rank_fusion_respects_top_k() {
let docs: Vec<DocumentId> = (0..10).map(|i| DocumentId::new(0, i)).collect();
let result = super::reciprocal_rank_fusion(vec![docs], vec![1.0], 3);
assert_eq!(result.len(), 3, "result should be capped at top_k=3");
}
#[test]
fn reciprocal_rank_fusion_weights_affect_ranking() {
let doc_a = DocumentId::new(0, 0);
let doc_b = DocumentId::new(0, 1);
let result = super::reciprocal_rank_fusion(
vec![vec![doc_a, doc_b], vec![doc_b, doc_a]],
vec![10.0, 1.0],
2,
);
assert_eq!(
result[0], doc_a,
"higher-weight signal's top doc should rank first"
);
}
} }
+2072 -12
View File
File diff suppressed because it is too large Load Diff
+21 -6
View File
@@ -2,7 +2,7 @@ use super::{MarkdownRender, SseEvent};
use crate::utils::{AbortSignal, poll_abort_signal, spawn_spinner}; use crate::utils::{AbortSignal, poll_abort_signal, spawn_spinner};
use anyhow::{Error, Result}; use anyhow::Result;
use crossterm::{ use crossterm::{
cursor, queue, style, cursor, queue, style,
terminal::{self, disable_raw_mode, enable_raw_mode}, terminal::{self, disable_raw_mode, enable_raw_mode},
@@ -74,6 +74,8 @@ async fn markdown_stream_inner(
let mut buffer_rows = 1; let mut buffer_rows = 1;
let columns = terminal::size()?.0; let columns = terminal::size()?.0;
let mut last_col: u16 = 0;
let mut last_row: u16 = 0;
let mut spinner = Some(spawn_spinner("Generating")); let mut spinner = Some(spawn_spinner("Generating"));
@@ -94,9 +96,16 @@ async fn markdown_stream_inner(
let mut attempts = 0; let mut attempts = 0;
let (col, mut row) = loop { let (col, mut row) = loop {
match cursor::position() { match cursor::position() {
Ok(pos) => break pos, Ok(pos) => {
Err(_) if attempts < 3 => attempts += 1, last_col = pos.0;
Err(e) => return Err(Error::from(e)), last_row = pos.1;
break pos;
}
Err(_) if attempts < 5 => {
attempts += 1;
tokio::time::sleep(Duration::from_millis(20)).await;
}
Err(_) => break (last_col, last_row),
} }
}; };
@@ -123,7 +132,9 @@ async fn markdown_stream_inner(
let text = format!("{buffer}{text}"); let text = format!("{buffer}{text}");
let (head, tail) = split_line_tail(&text); let (head, tail) = split_line_tail(&text);
let output = render.render(head); let output = render.render(head);
print_block(writer, &output, columns)?; if !output.is_empty() {
print_block(writer, &output, columns)?;
}
buffer = tail.to_string(); buffer = tail.to_string();
} else { } else {
buffer = format!("{buffer}{text}"); buffer = format!("{buffer}{text}");
@@ -142,10 +153,14 @@ async fn markdown_stream_inner(
queue!(writer, style::Print(&output))?; queue!(writer, style::Print(&output))?;
buffer_rows = need_rows(&output, columns); buffer_rows = need_rows(&output, columns);
} }
writer.flush()?; writer.flush()?;
} }
SseEvent::Done => { SseEvent::Done => {
let tail = render.finalize();
if !tail.is_empty() {
queue!(writer, style::Print("\n"), style::Print(&tail))?;
writer.flush()?;
}
break 'outer; break 'outer;
} }
} }
+2
View File
@@ -31,6 +31,7 @@ impl Completer for ReplCompleter {
let ctx = self.ctx.read(); let ctx = self.ctx.read();
let state = ctx.state(); let state = ctx.state();
let model_has_reasoning = !ctx.current_model().reasoning_levels().is_empty();
let command_filter = parts let command_filter = parts
.iter() .iter()
@@ -44,6 +45,7 @@ impl Completer for ReplCompleter {
.filter(|cmd| { .filter(|cmd| {
cmd.is_valid(state) cmd.is_valid(state)
&& (command_filter.len() == 1 || cmd.name.starts_with(&command_filter[..2])) && (command_filter.len() == 1 || cmd.name.starts_with(&command_filter[..2]))
&& (cmd.name != ".reasoning" || model_has_reasoning)
}) })
.collect(); .collect();
let commands = fuzzy_filter(commands, |v| v.name, &command_filter); let commands = fuzzy_filter(commands, |v| v.name, &command_filter);
+148 -90
View File
@@ -1,15 +1,13 @@
mod completer; mod completer;
mod highlighter; mod highlighter;
mod prompt; mod prompt;
mod replay;
use self::completer::ReplCompleter; use self::completer::ReplCompleter;
use self::highlighter::ReplHighlighter; use self::highlighter::ReplHighlighter;
use self::prompt::ReplPrompt; use self::prompt::ReplPrompt;
use crate::client::{ use crate::client::{call_chat_completions, call_chat_completions_streaming, init_client, oauth};
Message, MessageRole, call_chat_completions, call_chat_completions_streaming, init_client,
oauth,
};
use crate::config::{ use crate::config::{
AgentVariables, AppConfig, AssertState, Input, LastMessage, RequestContext, StateFlags, AgentVariables, AppConfig, AssertState, Input, LastMessage, RequestContext, StateFlags,
macro_execute, macro_execute,
@@ -52,7 +50,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
4. Continue with the next pending item now. Call tools immediately." 4. Continue with the next pending item now. Call tools immediately."
}; };
static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| { static REPL_COMMANDS: LazyLock<[ReplCommand; 58]> = LazyLock::new(|| {
[ [
ReplCommand::new(".help", "Show this help guide", AssertState::pass()), ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
ReplCommand::new(".info", "Show system info", AssertState::pass()), ReplCommand::new(".info", "Show system info", AssertState::pass()),
@@ -71,6 +69,26 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
"Authenticate with an MCP server via OAuth", "Authenticate with an MCP server via OAuth",
AssertState::pass(), AssertState::pass(),
), ),
ReplCommand::new(
".mcp enable",
"Enable a single MCP server in the current context",
AssertState::pass(),
),
ReplCommand::new(
".mcp disable",
"Disable a single MCP server in the current context",
AssertState::pass(),
),
ReplCommand::new(
".tool enable",
"Enable a single tool in the current context",
AssertState::True(StateFlags::FUNCTION_CALLING),
),
ReplCommand::new(
".tool disable",
"Disable a single tool in the current context",
AssertState::True(StateFlags::FUNCTION_CALLING),
),
ReplCommand::new( ReplCommand::new(
".edit config", ".edit config",
"Modify configuration file", "Modify configuration file",
@@ -125,6 +143,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
"Clear session messages", "Clear session messages",
AssertState::True(StateFlags::SESSION), AssertState::True(StateFlags::SESSION),
), ),
ReplCommand::new(
".undo",
"Undo the last exchange and restore the prompt",
AssertState::True(StateFlags::SESSION),
),
ReplCommand::new( ReplCommand::new(
".compress session", ".compress session",
"Compress session messages", "Compress session messages",
@@ -150,6 +173,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
"Exit active session", "Exit active session",
AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION), AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION),
), ),
ReplCommand::new(
".fork",
"Fork the active session into a new named copy",
AssertState::True(StateFlags::SESSION),
),
ReplCommand::new(".agent", "Use an agent", AssertState::bare()), ReplCommand::new(".agent", "Use an agent", AssertState::bare()),
ReplCommand::new( ReplCommand::new(
".starter", ".starter",
@@ -254,11 +282,21 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
), ),
ReplCommand::new(".copy", "Copy last response", AssertState::pass()), ReplCommand::new(".copy", "Copy last response", AssertState::pass()),
ReplCommand::new(".set", "Modify runtime settings", AssertState::pass()), ReplCommand::new(".set", "Modify runtime settings", AssertState::pass()),
ReplCommand::new(
".reasoning",
"Set the reasoning effort level for the current model",
AssertState::pass(),
),
ReplCommand::new( ReplCommand::new(
".delete", ".delete",
"Delete roles, sessions, RAGs, or agents", "Delete roles, sessions, RAGs, or agents",
AssertState::pass(), AssertState::pass(),
), ),
ReplCommand::new(
".list",
"List roles, sessions, agents, RAGs, macros, skills, tools, or MCP servers",
AssertState::pass(),
),
ReplCommand::new( ReplCommand::new(
".vault", ".vault",
"View or modify the Coyote vault", "View or modify the Coyote vault",
@@ -322,54 +360,16 @@ Type ".help" for additional help.
} }
{ {
let (messages_snapshot, compressed_count) = { let (compressed, active) = {
let ctx = self.ctx.read(); let ctx = self.ctx.read();
if let Some(session) = &ctx.session { match &ctx.session {
let msgs: Vec<Message> = session Some(session) => replay::snapshot(session),
.messages() None => (Vec::new(), Vec::new()),
.iter()
.filter(|m| !m.role.is_system())
.cloned()
.collect();
let compressed = session.compressed_messages().len();
(msgs, compressed)
} else {
(vec![], 0)
} }
}; };
if !compressed.is_empty() || !active.is_empty() {
if !messages_snapshot.is_empty() || compressed_count > 0 {
let app = Arc::clone(&self.ctx.read().app.config); let app = Arc::clone(&self.ctx.read().app.config);
if compressed_count > 0 { replay::render(app.as_ref(), &compressed, &active)?;
println!(
"{}",
dimmed_text(&format!(
"({compressed_count} earlier messages not shown; compressed for context)"
))
);
println!();
}
for message in &messages_snapshot {
match message.role {
MessageRole::User => {
if let Some(text) = message.content.as_text() {
println!("{}", dimmed_text("You:"));
println!("{text}");
println!();
}
}
MessageRole::Assistant => {
if let Some(text) = message.content.as_text() {
app.print_markdown(text)?;
println!();
}
}
_ => {}
}
}
println!("{}", dimmed_text("─── ↑ previous conversation ↑ ───"));
println!();
} }
} }
@@ -390,6 +390,10 @@ Type ".help" for additional help.
if exit { if exit {
break; break;
} }
if let Some(text) = self.ctx.write().pending_prefill.take() {
self.editor
.run_edit_commands(&[EditCommand::InsertString(text)]);
}
} }
Err(err) => { Err(err) => {
render_error(err); render_error(err);
@@ -659,14 +663,69 @@ pub async fn run_repl_command(
) )
.await?; .await?;
println!("Authentication saved."); println!("Authentication saved.");
if ctx.app.config.mcp_server_support {
let app = Arc::clone(&ctx.app.config);
ctx.bootstrap_tools(
app.as_ref(),
true,
abort_signal.clone(),
)
.await?;
if ctx.tool_scope.mcp_runtime.get(server_name).is_some()
{
println!(
"✓ MCP server '{server_name}' started and attached to the current context."
);
} else {
println!(
"MCP server '{server_name}' is not enabled in the current context. \
Run `.mcp enable {server_name}` to attach it."
);
}
}
} }
} }
} }
} }
"enable" | "disable" => {
if rest.is_empty() {
println!("Usage: .mcp {sub} <server_name>");
} else {
ctx.toggle_mcp_server(sub, rest, abort_signal.clone())
.await?;
}
}
_ => unknown_command()?, _ => unknown_command()?,
} }
} }
None => println!("Usage: .mcp auth <server_name>"), None => println!(
r#"Usage:
.mcp auth <server_name> # Authenticate with an MCP server via OAuth
.mcp enable <server_name> # Enable a single MCP server in the current context
.mcp disable <server_name> # Disable a single MCP server in the current context"#
),
},
".tool" => match args {
Some(args) => {
let mut parts = args.splitn(2, char::is_whitespace);
let sub = parts.next().unwrap_or("").trim();
let rest = parts.next().map(str::trim).unwrap_or("");
match sub {
"enable" | "disable" => {
if rest.is_empty() {
println!("Usage: .tool {sub} <name>");
} else {
ctx.toggle_tool(sub, rest)?;
}
}
_ => unknown_command()?,
}
}
None => println!(
r#"Usage:
.tool enable <name> # Enable a single tool in the current context
.tool disable <name> # Disable a single tool in the current context"#
),
}, },
".prompt" => match args { ".prompt" => match args {
Some(text) => { Some(text) => {
@@ -760,44 +819,8 @@ pub async fn run_repl_command(
} }
} }
if let Some(session) = &ctx.session { if let Some(session) = &ctx.session {
let messages_snapshot: Vec<Message> = session let (compressed, active) = replay::snapshot(session);
.messages() replay::render(app.as_ref(), &compressed, &active)?;
.iter()
.filter(|m| !m.role.is_system())
.cloned()
.collect();
let compressed_count = session.compressed_messages().len();
if !messages_snapshot.is_empty() || compressed_count > 0 {
if compressed_count > 0 {
println!(
"{}",
dimmed_text(&format!(
"({compressed_count} earlier messages not shown — compressed for context)"
))
);
println!();
}
for message in &messages_snapshot {
match message.role {
MessageRole::User => {
if let Some(text) = message.content.as_text() {
println!("{}", dimmed_text("You:"));
println!("{text}");
println!();
}
}
MessageRole::Assistant => {
if let Some(text) = message.content.as_text() {
app.print_markdown(text)?;
println!();
}
}
_ => {}
}
}
println!("{}", dimmed_text("─── ↑ previous conversation ↑ ───"));
println!();
}
} }
} }
".install" => { ".install" => {
@@ -853,6 +876,10 @@ pub async fn run_repl_command(
let app = Arc::clone(&ctx.app.config); let app = Arc::clone(&ctx.app.config);
ctx.use_agent(app.as_ref(), agent_name, session_name, abort_signal.clone()) ctx.use_agent(app.as_ref(), agent_name, session_name, abort_signal.clone())
.await?; .await?;
if let Some(session) = &ctx.session {
let (compressed, active) = replay::snapshot(session);
replay::render(app.as_ref(), &compressed, &active)?;
}
} }
None => { None => {
println!(r#"Usage: .agent <agent-name> [session-name] [key=value]..."#) println!(r#"Usage: .agent <agent-name> [session-name] [key=value]..."#)
@@ -884,6 +911,9 @@ pub async fn run_repl_command(
ctx.app.config.print_markdown(&banner)?; ctx.app.config.print_markdown(&banner)?;
} }
}, },
".fork" => {
ctx.fork_session(args)?;
}
".save" => match split_first_arg(args) { ".save" => match split_first_arg(args) {
Some(("role", name)) => { Some(("role", name)) => {
ctx.save_role(name)?; ctx.save_role(name)?;
@@ -966,6 +996,15 @@ pub async fn run_repl_command(
println!(r#"Usage: .empty session"#) println!(r#"Usage: .empty session"#)
} }
}, },
".undo" => {
if let Some(name) = graph::active_agent_graph_name(ctx) {
bail!(
"Graph-based agent '{name}' does not support .undo. \
The graph manages its own state."
);
}
ctx.undo_last_exchange()?;
}
".rebuild" => match args { ".rebuild" => match args {
Some("rag") => { Some("rag") => {
ctx.rebuild_rag(abort_signal.clone()).await?; ctx.rebuild_rag(abort_signal.clone()).await?;
@@ -1052,6 +1091,15 @@ pub async fn run_repl_command(
println!("Usage: .set <key> <value>...") println!("Usage: .set <key> <value>...")
} }
}, },
".reasoning" => match args {
Some(level) => {
let set_args = format!("reasoning_effort {level}");
ctx.update(&set_args, abort_signal).await?;
}
None => {
println!("Usage: .reasoning <level>")
}
},
".delete" => match args { ".delete" => match args {
Some(args) => { Some(args) => {
ctx.delete(args)?; ctx.delete(args)?;
@@ -1060,6 +1108,16 @@ pub async fn run_repl_command(
println!("Usage: .delete <role|session|rag|macro|skill|agent-data>") println!("Usage: .delete <role|session|rag|macro|skill|agent-data>")
} }
}, },
".list" => match args {
Some(args) => {
ctx.list_assets(args.trim())?;
}
_ => {
println!(
"Usage: .list <roles|sessions|agents|rags|macros|skills|tools|mcp-servers>"
)
}
},
".copy" => { ".copy" => {
let output = match ctx let output = match ctx
.last_message .last_message
@@ -1582,8 +1640,8 @@ mod tests {
} }
#[test] #[test]
fn repl_commands_has_50_entries() { fn repl_commands_has_58_entries() {
assert_eq!(REPL_COMMANDS.len(), 50); assert_eq!(REPL_COMMANDS.len(), 58);
} }
#[test] #[test]
+59
View File
@@ -0,0 +1,59 @@
use anyhow::Result;
use crate::client::{Message, MessageRole};
use crate::config::{AppConfig, Session};
use crate::utils::dimmed_text;
pub fn snapshot(session: &Session) -> (Vec<Message>, Vec<Message>) {
(
filter_for_display(session.compressed_messages()),
filter_for_display(session.messages()),
)
}
pub fn render(app: &AppConfig, compressed: &[Message], active: &[Message]) -> Result<()> {
if compressed.is_empty() && active.is_empty() {
return Ok(());
}
render_messages(app, compressed)?;
if !compressed.is_empty() && !active.is_empty() {
println!("{}", dimmed_text("─── ↑ pre-compression history ↑ ───"));
println!();
}
render_messages(app, active)?;
println!("{}", dimmed_text("─── ↑ previous conversation ↑ ───"));
println!();
Ok(())
}
fn filter_for_display(messages: &[Message]) -> Vec<Message> {
messages
.iter()
.filter(|m| !m.role.is_system())
.cloned()
.collect()
}
fn render_messages(app: &AppConfig, messages: &[Message]) -> Result<()> {
for message in messages {
match message.role {
MessageRole::User => {
if let Some(text) = message.content.as_text() {
println!("{}", dimmed_text("You:"));
println!("{text}");
println!();
}
}
MessageRole::Assistant => {
if let Some(text) = message.content.as_text() {
app.print_markdown(text)?;
println!();
}
}
_ => {}
}
}
Ok(())
}
+21
View File
@@ -388,6 +388,27 @@ fn copy_host_files(name: &str) -> Result<()> {
); );
} }
let oauth_tokens_dir = paths::oauth_tokens_dir();
if oauth_tokens_dir.exists() {
let sandbox_cache_dir = "/home/agent/.cache";
let sandbox_oauth_dir = "/home/agent/.cache/coyote/oauth";
ensure_sandbox_dir(name, sandbox_oauth_dir)?;
let dest = format!("{name}:{sandbox_oauth_dir}/");
for entry in fs::read_dir(&oauth_tokens_dir)
.with_context(|| format!("Failed to read {}", oauth_tokens_dir.display()))?
{
let entry = entry?;
let path = entry.path();
sbx_cp(&path.display().to_string(), &dest)?;
}
chown_agent_recursive(name, sandbox_cache_dir)?;
} else {
debug!(
"Skipping OAuth token copy: {} does not exist",
oauth_tokens_dir.display()
);
}
match resolve_vault_password_file() { match resolve_vault_password_file() {
Some(password_file) if password_file.exists() => { Some(password_file) if password_file.exists() => {
let dest_path = host_to_sandbox_path(&password_file, &home_dir, cfg!(windows))?; let dest_path = host_to_sandbox_path(&password_file, &home_dir, cfg!(windows))?;
+1 -1
View File
@@ -9,7 +9,7 @@ use tokio::time::sleep;
pub async fn tail_logs(no_color: bool) { pub async fn tail_logs(no_color: bool) {
let re = Regex::new(r"^(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+<(?P<opid>[^\s>]+)>\s+\[(?P<level>[A-Z]+)\]\s+(?P<logger>[^:]+):(?P<line>\d+)\s+-\s+(?P<message>.*)$").unwrap(); let re = Regex::new(r"^(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+<(?P<opid>[^\s>]+)>\s+\[(?P<level>[A-Z]+)\]\s+(?P<logger>[^:]+):(?P<line>\d+)\s+-\s+(?P<message>.*)$").unwrap();
let file_path = paths::log_path(); let file_path = paths::log_file();
let file = File::open(&file_path).expect("Cannot open file"); let file = File::open(&file_path).expect("Cannot open file");
let mut reader = BufReader::new(file); let mut reader = BufReader::new(file);
+1 -1
View File
@@ -34,7 +34,7 @@ fn apply_sandboxed_home_translation(provider_def: &mut LocalProvider) {
return; return;
} }
let Some(translated) = paths::translate_sandboxed_home_path(pf) else { let Some(translated) = paths::translate_sandboxed_home_dir(pf) else {
return; return;
}; };